From 21cac2d9bac5a57c7dddad5fc3ce00e80629d868 Mon Sep 17 00:00:00 2001 From: Phuoc Hiep Date: Sat, 28 Feb 2026 00:09:35 +0700 Subject: [PATCH 001/781] fix: Use contains operator for merchant filter in advanced search Switch merchant filter from : (eq) to *: (contains) operator to preserve partial-matching UX when backend starts respecting eq vs contains distinction. Changes: - Add *: operator support to parser grammar (baseRules.peggy) - Regenerate searchParser.js and autocompleteParser.js - Update buildQueryStringFromFilterFormValues to use *: for merchant - Update unit test expectations Fixes https://github.com/Expensify/App/issues/80823 --- src/libs/SearchParser/autocompleteParser.js | 3269 ++++++------------- src/libs/SearchParser/baseRules.peggy | 5 +- src/libs/SearchParser/searchParser.js | 1706 +++++----- src/libs/SearchQueryUtils.ts | 6 +- tests/unit/Search/SearchQueryUtilsTest.ts | 4 +- 5 files changed, 1828 insertions(+), 3162 deletions(-) diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index e70056f0d519..aed140261dfa 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -1,7 +1,7 @@ 'worklet' class peg$SyntaxError{} -// @generated by Peggy 4.0.3. +// @generated by Peggy 5.0.6. // // https://peggyjs.org/ @@ -11,598 +11,494 @@ class peg$SyntaxError{} // To make changes, edit the corresponding *.peggy files only. // Use the `generate-search-parser` and `generate-autocomplete-parser` scripts to regenerate parsers after modifications. -function peg$subclass(child, parent) { - function C() { this.constructor = child; } - C.prototype = parent.prototype; - child.prototype = new C(); -} - -function temporary(message, expected, found, location) { - var self = Error.call(this, message); - // istanbul ignore next Check is a necessary evil to support older environments - if (Object.setPrototypeOf) { - Object.setPrototypeOf(self, peg$SyntaxError.prototype); - } - self.expected = expected; - self.found = found; - self.location = location; - self.name = "SyntaxError"; - return self; -} - - - -function peg$padEnd(str, targetLength, padString) { - padString = padString || " "; - if (str.length > targetLength) { return str; } - targetLength -= str.length; - padString += padString.repeat(targetLength); - return str + padString.slice(0, targetLength); -} +class peg$SyntaxError extends SyntaxError { + constructor(message, expected, found, location) { + super(message); + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + } + + format(sources) { + let str = "Error: " + this.message; + if (this.location) { + let src = null; + const st = sources.find(s => s.source === this.location.source); + if (st) { + src = st.text.split(/\r\n|\n|\r/g); + } + const s = this.location.start; + const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + const e = this.location.end; + const filler = "".padEnd(offset_s.line.toString().length, " "); + const line = src[s.line - 1]; + const last = s.line === e.line ? e.column : line.length + 1; + const hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + "".padEnd(s.column - 1, " ") + + "".padEnd(hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; + } + + static buildMessage(expected, found) { + function hex(ch) { + return ch.codePointAt(0).toString(16).toUpperCase(); + } + + const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") + ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") + : null; + function unicodeEscape(s) { + if (nonPrintable) { + return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); + } + return s; + } + + function literalEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + function classEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + const DESCRIBE_EXPECTATION_FNS = { + literal(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, -peg$SyntaxError.prototype.format = function(sources) { - var str = "Error: " + this.message; - if (this.location) { - var src = null; - var k; - for (k = 0; k < sources.length; k++) { - if (sources[k].source === this.location.source) { - src = sources[k].text.split(/\r\n|\n|\r/g); - break; - } - } - var s = this.location.start; - var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) - ? this.location.source.offset(s) - : s; - var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; - if (src) { - var e = this.location.end; - var filler = peg$padEnd("", offset_s.line.toString().length, ' '); - var line = src[s.line - 1]; - var last = s.line === e.line ? e.column : line.length + 1; - var hatLen = (last - s.column) || 1; - str += "\n --> " + loc + "\n" - + filler + " |\n" - + offset_s.line + " | " + line + "\n" - + filler + " | " + peg$padEnd("", s.column - 1, ' ') - + peg$padEnd("", hatLen, "^"); - } else { - str += "\n at " + loc; - } - } - return str; -}; + class(expectation) { + const escapedParts = expectation.parts.map( + part => (Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part)) + ); -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, - - class: function(expectation) { - var escapedParts = expectation.parts.map(function(part) { - return Array.isArray(part) - ? classEscape(part[0]) + "-" + classEscape(part[1]) - : classEscape(part); - }); + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); + }, - return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; - }, + any() { + return "any character"; + }, - any: function() { - return "any character"; - }, + end() { + return "end of input"; + }, - end: function() { - return "end of input"; - }, + other(expectation) { + return expectation.description; + }, + }; - other: function(expectation) { - return expectation.description; + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); } - }; - - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } - function literalEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } - - function classEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/\]/g, "\\]") - .replace(/\^/g, "\\^") - .replace(/-/g, "\\-") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } + function describeExpected(expected) { + const descriptions = expected.map(describeExpectation); + descriptions.sort(); - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } + if (descriptions.length > 0) { + let j = 1; + for (let i = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } - function describeExpected(expected) { - var descriptions = expected.map(describeExpectation); - var i, j; + switch (descriptions.length) { + case 1: + return descriptions[0]; - descriptions.sort(); + case 2: + return descriptions[0] + " or " + descriptions[1]; - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; } - descriptions.length = j; } - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; } - } - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; +} function peg$parse(input, options) { options = options !== undefined ? options : {}; - var peg$FAILED = {}; - var peg$source = options.grammarSource; - - var peg$startRuleFunctions = { query: peg$parsequery }; - var peg$startRuleFunction = peg$parsequery; - - var peg$c0 = "-"; - var peg$c1 = "report-field"; - var peg$c2 = "reportfield"; - var peg$c3 = ","; - var peg$c4 = "date"; - var peg$c5 = "amount"; - var peg$c6 = "merchant"; - var peg$c7 = "description"; - var peg$c8 = "reportid"; - var peg$c9 = "report-id"; - var peg$c10 = "keyword"; - var peg$c11 = "in"; - var peg$c12 = "currency"; - var peg$c13 = "groupCurrency"; - var peg$c14 = "group-currency"; - var peg$c15 = "tag"; - var peg$c16 = "category"; - var peg$c17 = "to"; - var peg$c18 = "exporter"; - var peg$c19 = "payer"; - var peg$c20 = "taxrate"; - var peg$c21 = "tax-rate"; - var peg$c22 = "cardID"; - var peg$c23 = "card"; - var peg$c24 = "from"; - var peg$c25 = "attendee"; - var peg$c26 = "expenseType"; - var peg$c27 = "expense-type"; - var peg$c28 = "withdrawalType"; - var peg$c29 = "withdrawal-type"; - var peg$c30 = "withdrawalid"; - var peg$c31 = "withdrawal-id"; - var peg$c32 = "billable"; - var peg$c33 = "reimbursable"; - var peg$c34 = "type"; - var peg$c35 = "status"; - var peg$c36 = "sortBy"; - var peg$c37 = "sort-by"; - var peg$c38 = "sortOrder"; - var peg$c39 = "sort-order"; - var peg$c40 = "policyID"; - var peg$c41 = "workspace"; - var peg$c42 = "submitted"; - var peg$c43 = "approved"; - var peg$c44 = "paid"; - var peg$c45 = "exported"; - var peg$c46 = "posted"; - var peg$c47 = "withdrawn"; - var peg$c48 = "groupby"; - var peg$c49 = "group-by"; - var peg$c50 = "limit"; - var peg$c51 = "feed"; - var peg$c52 = "title"; - var peg$c53 = "assignee"; - var peg$c54 = "createdby"; - var peg$c55 = "created-by"; - var peg$c56 = "action"; - var peg$c57 = "total"; - var peg$c58 = "has"; - var peg$c59 = "is"; - var peg$c60 = "purchaseamount"; - var peg$c61 = "purchase-amount"; - var peg$c62 = "purchasecurrency"; - var peg$c63 = "purchase-currency"; - var peg$c64 = "columns"; - var peg$c65 = "view"; - var peg$c66 = "per-diem"; - var peg$c67 = "drafts"; - var peg$c68 = "draft"; - var peg$c69 = "original-amount"; - var peg$c70 = "tax"; - var peg$c71 = "policy-name"; - var peg$c72 = "bank-account"; - var peg$c73 = "long-report-id"; - var peg$c74 = "exported-to"; - var peg$c75 = "exportedto"; - var peg$c76 = "exchange-rate"; - var peg$c77 = "reimbursable-total"; - var peg$c78 = "non-reimbursable-total"; - var peg$c79 = "group-from"; - var peg$c80 = "group-expenses"; - var peg$c81 = "group-total"; - var peg$c82 = "group-card"; - var peg$c83 = "group-feed"; - var peg$c84 = "group-bank-account"; - var peg$c85 = "group-withdrawn"; - var peg$c86 = "group-withdrawal-id"; - var peg$c87 = "group-category"; - var peg$c88 = "group-tag"; - var peg$c89 = "group-merchant"; - var peg$c90 = "group-month"; - var peg$c91 = "group-week"; - var peg$c92 = "group-year"; - var peg$c93 = "group-quarter"; - var peg$c94 = "!="; - var peg$c95 = ">="; - var peg$c96 = ">"; - var peg$c97 = "<="; - var peg$c98 = "<"; - var peg$c99 = "\u201C"; - var peg$c100 = "\u201D"; - var peg$c101 = "\""; - - var peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; - var peg$r1 = /^[:=]/; - var peg$r2 = /^[^ ,\t\n\r\xA0]/; - var peg$r3 = /^[ \t\r\n\xA0]/; - var peg$r4 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; - var peg$r5 = /^["\u201C-\u201D]/; - var peg$r6 = /^[^"\u201D\u201C\r\n]/; - var peg$r7 = /^[ \t\n\r\xA0a-zA-Z0-9]/; - var peg$r8 = /^[a-zA-Z0-9]/; - var peg$r9 = /^[ \t\n\r\xA0]/; - var peg$r10 = /^[ \t\n\r\xA0a-zA-Z]/; - var peg$r11 = /^[,]/; - var peg$r12 = /^[ \t\n\r\xA0,]/; - - var peg$e0 = peg$literalExpectation("-", false); - var peg$e1 = peg$literalExpectation("report-field", true); - var peg$e2 = peg$literalExpectation("reportfield", true); - var peg$e3 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false); - var peg$e4 = peg$anyExpectation(); - var peg$e5 = peg$literalExpectation(",", false); - var peg$e6 = peg$otherExpectation("key"); - var peg$e7 = peg$literalExpectation("date", true); - var peg$e8 = peg$literalExpectation("amount", true); - var peg$e9 = peg$literalExpectation("merchant", true); - var peg$e10 = peg$literalExpectation("description", true); - var peg$e11 = peg$literalExpectation("reportid", true); - var peg$e12 = peg$literalExpectation("report-id", true); - var peg$e13 = peg$literalExpectation("keyword", true); - var peg$e14 = peg$literalExpectation("in", true); - var peg$e15 = peg$literalExpectation("currency", true); - var peg$e16 = peg$literalExpectation("groupCurrency", false); - var peg$e17 = peg$literalExpectation("group-currency", true); - var peg$e18 = peg$literalExpectation("tag", true); - var peg$e19 = peg$literalExpectation("category", true); - var peg$e20 = peg$literalExpectation("to", true); - var peg$e21 = peg$literalExpectation("exporter", true); - var peg$e22 = peg$literalExpectation("payer", true); - var peg$e23 = peg$literalExpectation("taxRate", true); - var peg$e24 = peg$literalExpectation("tax-rate", true); - var peg$e25 = peg$literalExpectation("cardID", false); - var peg$e26 = peg$literalExpectation("card", true); - var peg$e27 = peg$literalExpectation("from", true); - var peg$e28 = peg$literalExpectation("attendee", true); - var peg$e29 = peg$literalExpectation("expenseType", false); - var peg$e30 = peg$literalExpectation("expense-type", true); - var peg$e31 = peg$literalExpectation("withdrawalType", false); - var peg$e32 = peg$literalExpectation("withdrawal-type", true); - var peg$e33 = peg$literalExpectation("withdrawalID", true); - var peg$e34 = peg$literalExpectation("withdrawal-id", true); - var peg$e35 = peg$literalExpectation("billable", true); - var peg$e36 = peg$literalExpectation("reimbursable", true); - var peg$e37 = peg$literalExpectation("type", true); - var peg$e38 = peg$literalExpectation("status", true); - var peg$e39 = peg$literalExpectation("sortBy", false); - var peg$e40 = peg$literalExpectation("sort-by", true); - var peg$e41 = peg$literalExpectation("sortOrder", false); - var peg$e42 = peg$literalExpectation("sort-order", true); - var peg$e43 = peg$literalExpectation("policyID", false); - var peg$e44 = peg$literalExpectation("workspace", true); - var peg$e45 = peg$literalExpectation("submitted", true); - var peg$e46 = peg$literalExpectation("approved", true); - var peg$e47 = peg$literalExpectation("paid", true); - var peg$e48 = peg$literalExpectation("exported", true); - var peg$e49 = peg$literalExpectation("posted", true); - var peg$e50 = peg$literalExpectation("withdrawn", true); - var peg$e51 = peg$literalExpectation("groupBy", true); - var peg$e52 = peg$literalExpectation("group-by", true); - var peg$e53 = peg$literalExpectation("limit", true); - var peg$e54 = peg$literalExpectation("feed", true); - var peg$e55 = peg$literalExpectation("title", true); - var peg$e56 = peg$literalExpectation("assignee", true); - var peg$e57 = peg$literalExpectation("createdBy", true); - var peg$e58 = peg$literalExpectation("created-by", true); - var peg$e59 = peg$literalExpectation("action", true); - var peg$e60 = peg$literalExpectation("total", true); - var peg$e61 = peg$literalExpectation("has", true); - var peg$e62 = peg$literalExpectation("is", true); - var peg$e63 = peg$literalExpectation("purchaseAmount", true); - var peg$e64 = peg$literalExpectation("purchase-amount", true); - var peg$e65 = peg$literalExpectation("purchaseCurrency", true); - var peg$e66 = peg$literalExpectation("purchase-currency", true); - var peg$e67 = peg$literalExpectation("columns", true); - var peg$e68 = peg$literalExpectation("view", true); - var peg$e69 = peg$literalExpectation("per-diem", true); - var peg$e70 = peg$literalExpectation("drafts", true); - var peg$e71 = peg$literalExpectation("draft", true); - var peg$e72 = peg$literalExpectation("original-amount", true); - var peg$e73 = peg$literalExpectation("tax", true); - var peg$e74 = peg$literalExpectation("policy-name", true); - var peg$e75 = peg$literalExpectation("bank-account", true); - var peg$e76 = peg$literalExpectation("long-report-id", true); - var peg$e77 = peg$literalExpectation("exported-to", true); - var peg$e78 = peg$classExpectation([":", "="], false, false); - var peg$e79 = peg$literalExpectation("exportedTo", true); - var peg$e80 = peg$literalExpectation("exchange-rate", true); - var peg$e81 = peg$literalExpectation("reimbursable-total", true); - var peg$e82 = peg$literalExpectation("non-reimbursable-total", true); - var peg$e83 = peg$literalExpectation("group-from", true); - var peg$e84 = peg$literalExpectation("group-expenses", true); - var peg$e85 = peg$literalExpectation("group-total", true); - var peg$e86 = peg$literalExpectation("group-card", true); - var peg$e87 = peg$literalExpectation("group-feed", true); - var peg$e88 = peg$literalExpectation("group-bank-account", true); - var peg$e89 = peg$literalExpectation("group-withdrawn", true); - var peg$e90 = peg$literalExpectation("group-withdrawal-id", true); - var peg$e91 = peg$literalExpectation("group-category", true); - var peg$e92 = peg$literalExpectation("group-tag", true); - var peg$e93 = peg$literalExpectation("group-merchant", true); - var peg$e94 = peg$literalExpectation("group-month", true); - var peg$e95 = peg$literalExpectation("group-week", true); - var peg$e96 = peg$literalExpectation("group-year", true); - var peg$e97 = peg$literalExpectation("group-quarter", true); - var peg$e98 = peg$otherExpectation("operator"); - var peg$e99 = peg$literalExpectation("!=", false); - var peg$e100 = peg$literalExpectation(">=", false); - var peg$e101 = peg$literalExpectation(">", false); - var peg$e102 = peg$literalExpectation("<=", false); - var peg$e103 = peg$literalExpectation("<", false); - var peg$e104 = peg$otherExpectation("word"); - var peg$e105 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e106 = peg$otherExpectation("whitespace"); - var peg$e107 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e108 = peg$otherExpectation("quote"); - var peg$e109 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e110 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e111 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e112 = peg$literalExpectation("\u201C", false); - var peg$e113 = peg$literalExpectation("\u201D", false); - var peg$e114 = peg$literalExpectation("\"", false); - var peg$e115 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e116 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e117 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e118 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e119 = peg$classExpectation([","], false, false); - var peg$e120 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - - var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; - var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; - var peg$f2 = function(neg, key, op, value) { - expectingNestedQuote = false; nameOperator = false; // Reset string parser - - const isNegated = !!neg && !nonNegatableKeys.has(key); - - if (!value) { - autocomplete = { - key, - negated: isNegated, - value: "", - start: location().end.offset, - length: 0, - }; - return; - } + const peg$FAILED = {}; + const peg$source = options.grammarSource; + const peg$startRuleFunctions = { + query: peg$parsequery, + }; + let peg$startRuleFunction = peg$parsequery; + + const peg$c0 = "-"; + const peg$c1 = "report-field"; + const peg$c2 = "reportfield"; + const peg$c3 = ","; + const peg$c4 = "date"; + const peg$c5 = "amount"; + const peg$c6 = "merchant"; + const peg$c7 = "description"; + const peg$c8 = "reportid"; + const peg$c9 = "report-id"; + const peg$c10 = "in"; + const peg$c11 = "currency"; + const peg$c12 = "groupCurrency"; + const peg$c13 = "group-currency"; + const peg$c14 = "tag"; + const peg$c15 = "category"; + const peg$c16 = "to"; + const peg$c17 = "exporter"; + const peg$c18 = "payer"; + const peg$c19 = "taxrate"; + const peg$c20 = "tax-rate"; + const peg$c21 = "cardID"; + const peg$c22 = "card"; + const peg$c23 = "from"; + const peg$c24 = "attendee"; + const peg$c25 = "expenseType"; + const peg$c26 = "expense-type"; + const peg$c27 = "withdrawalType"; + const peg$c28 = "withdrawal-type"; + const peg$c29 = "withdrawalid"; + const peg$c30 = "withdrawal-id"; + const peg$c31 = "billable"; + const peg$c32 = "reimbursable"; + const peg$c33 = "type"; + const peg$c34 = "status"; + const peg$c35 = "policyID"; + const peg$c36 = "workspace"; + const peg$c37 = "submitted"; + const peg$c38 = "approved"; + const peg$c39 = "paid"; + const peg$c40 = "exported"; + const peg$c41 = "posted"; + const peg$c42 = "withdrawn"; + const peg$c43 = "groupby"; + const peg$c44 = "group-by"; + const peg$c45 = "limit"; + const peg$c46 = "feed"; + const peg$c47 = "title"; + const peg$c48 = "assignee"; + const peg$c49 = "createdby"; + const peg$c50 = "created-by"; + const peg$c51 = "action"; + const peg$c52 = "total"; + const peg$c53 = "has"; + const peg$c54 = "is"; + const peg$c55 = "purchaseamount"; + const peg$c56 = "purchase-amount"; + const peg$c57 = "purchasecurrency"; + const peg$c58 = "purchase-currency"; + const peg$c59 = "columns"; + const peg$c60 = "view"; + const peg$c61 = "exportedto"; + const peg$c62 = "exported-to"; + const peg$c63 = "*:"; + const peg$c64 = "!="; + const peg$c65 = ">="; + const peg$c66 = ">"; + const peg$c67 = "<="; + const peg$c68 = "<"; + const peg$c69 = "\u201C"; + const peg$c70 = "\u201D"; + const peg$c71 = "\""; + + const peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; + const peg$r1 = /^[:=]/; + const peg$r2 = /^[^ ,\t\n\r\xA0]/; + const peg$r3 = /^[ \t\r\n\xA0]/; + const peg$r4 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; + const peg$r5 = /^["\u201C-\u201D]/; + const peg$r6 = /^[^"\u201D\u201C\r\n]/; + const peg$r7 = /^[ \t\n\r\xA0a-zA-Z0-9]/; + const peg$r8 = /^[a-zA-Z0-9]/; + const peg$r9 = /^[ \t\n\r\xA0]/; + const peg$r10 = /^[ \t\n\r\xA0a-zA-Z]/; + const peg$r11 = /^[,]/; + const peg$r12 = /^[ \t\n\r\xA0,]/; + + const peg$e0 = peg$literalExpectation("-", false); + const peg$e1 = peg$literalExpectation("report-field", true); + const peg$e2 = peg$literalExpectation("reportfield", true); + const peg$e3 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false, false); + const peg$e4 = peg$anyExpectation(); + const peg$e5 = peg$literalExpectation(",", false); + const peg$e6 = peg$otherExpectation("key"); + const peg$e7 = peg$literalExpectation("date", true); + const peg$e8 = peg$literalExpectation("amount", true); + const peg$e9 = peg$literalExpectation("merchant", true); + const peg$e10 = peg$literalExpectation("description", true); + const peg$e11 = peg$literalExpectation("reportid", true); + const peg$e12 = peg$literalExpectation("report-id", true); + const peg$e13 = peg$literalExpectation("in", true); + const peg$e14 = peg$literalExpectation("currency", true); + const peg$e15 = peg$literalExpectation("groupCurrency", false); + const peg$e16 = peg$literalExpectation("group-currency", true); + const peg$e17 = peg$literalExpectation("tag", true); + const peg$e18 = peg$literalExpectation("category", true); + const peg$e19 = peg$literalExpectation("to", true); + const peg$e20 = peg$literalExpectation("exporter", true); + const peg$e21 = peg$literalExpectation("payer", true); + const peg$e22 = peg$literalExpectation("taxRate", true); + const peg$e23 = peg$literalExpectation("tax-rate", true); + const peg$e24 = peg$literalExpectation("cardID", false); + const peg$e25 = peg$literalExpectation("card", true); + const peg$e26 = peg$literalExpectation("from", true); + const peg$e27 = peg$literalExpectation("attendee", true); + const peg$e28 = peg$literalExpectation("expenseType", false); + const peg$e29 = peg$literalExpectation("expense-type", true); + const peg$e30 = peg$literalExpectation("withdrawalType", false); + const peg$e31 = peg$literalExpectation("withdrawal-type", true); + const peg$e32 = peg$literalExpectation("withdrawalID", true); + const peg$e33 = peg$literalExpectation("withdrawal-id", true); + const peg$e34 = peg$literalExpectation("billable", true); + const peg$e35 = peg$literalExpectation("reimbursable", true); + const peg$e36 = peg$literalExpectation("type", true); + const peg$e37 = peg$literalExpectation("status", true); + const peg$e38 = peg$literalExpectation("policyID", false); + const peg$e39 = peg$literalExpectation("workspace", true); + const peg$e40 = peg$literalExpectation("submitted", true); + const peg$e41 = peg$literalExpectation("approved", true); + const peg$e42 = peg$literalExpectation("paid", true); + const peg$e43 = peg$literalExpectation("exported", true); + const peg$e44 = peg$literalExpectation("posted", true); + const peg$e45 = peg$literalExpectation("withdrawn", true); + const peg$e46 = peg$literalExpectation("groupBy", true); + const peg$e47 = peg$literalExpectation("group-by", true); + const peg$e48 = peg$literalExpectation("limit", true); + const peg$e49 = peg$literalExpectation("feed", true); + const peg$e50 = peg$literalExpectation("title", true); + const peg$e51 = peg$literalExpectation("assignee", true); + const peg$e52 = peg$literalExpectation("createdBy", true); + const peg$e53 = peg$literalExpectation("created-by", true); + const peg$e54 = peg$literalExpectation("action", true); + const peg$e55 = peg$literalExpectation("total", true); + const peg$e56 = peg$literalExpectation("has", true); + const peg$e57 = peg$literalExpectation("is", true); + const peg$e58 = peg$literalExpectation("purchaseAmount", true); + const peg$e59 = peg$literalExpectation("purchase-amount", true); + const peg$e60 = peg$literalExpectation("purchaseCurrency", true); + const peg$e61 = peg$literalExpectation("purchase-currency", true); + const peg$e62 = peg$literalExpectation("columns", true); + const peg$e63 = peg$literalExpectation("view", true); + const peg$e64 = peg$classExpectation([":", "="], false, false, false); + const peg$e65 = peg$literalExpectation("exportedTo", true); + const peg$e66 = peg$literalExpectation("exported-to", true); + const peg$e67 = peg$otherExpectation("operator"); + const peg$e68 = peg$literalExpectation("*:", false); + const peg$e69 = peg$literalExpectation("!=", false); + const peg$e70 = peg$literalExpectation(">=", false); + const peg$e71 = peg$literalExpectation(">", false); + const peg$e72 = peg$literalExpectation("<=", false); + const peg$e73 = peg$literalExpectation("<", false); + const peg$e74 = peg$otherExpectation("word"); + const peg$e75 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false, false); + const peg$e76 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false, false); + const peg$e77 = peg$otherExpectation("quote"); + const peg$e78 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false, false); + const peg$e79 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false, false); + const peg$e80 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false, false); + const peg$e81 = peg$literalExpectation("\u201C", false); + const peg$e82 = peg$literalExpectation("\u201D", false); + const peg$e83 = peg$literalExpectation("\"", false); + const peg$e84 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); + const peg$e85 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); + const peg$e86 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false, false); + const peg$e87 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false, false); + const peg$e88 = peg$classExpectation([","], false, false, false); + const peg$e89 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false, false); + + function peg$f0(ranges) { return { autocomplete, ranges }; } + function peg$f1(filters) { return filters.filter(Boolean).flat(); } + function peg$f2(neg, key, op, value) { + expectingNestedQuote = false; nameOperator = false; // Reset string parser + + const isNegated = !!neg && !nonNegatableKeys.has(key); + + if (!value) { autocomplete = { key, negated: isNegated, - ...value[value.length - 1], + value: "", + start: location().end.offset, + length: 0, }; - return value - .filter((v) => v.length > 0) - .map((v) => ({ - key, - negated: isNegated, - ...v, - })); - }; - var peg$f3 = function(rest) { - const suffix = rest.replaceAll(/^-+/g, ""); - return "reportField-" + (suffix ? suffix : ""); - }; - var peg$f4 = function() { autocomplete = null; }; - var peg$f5 = function(k) { - nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); - return k; + return; + } + + autocomplete = { + key, + negated: isNegated, + ...value[value.length - 1], }; - var peg$f6 = function(parts, empty) { - const ends = location(); - const value = parts.flat().filter(Boolean); // Filter out undefined values returned by the predicate - if (empty) { - value.push(""); - } - let count = ends.start.offset; - const result = []; - value.forEach((filter) => { - let word = filter; - if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { - word = word.slice(1, -1); - } - result.push({ - value: word, - start: count, - length: filter.length, - }); - count += filter.length + 1; + return value + .filter((v) => v.length > 0) + .map((v) => ({ + key, + negated: isNegated, + ...v, + })); + } + function peg$f3(rest) { + const suffix = rest.replaceAll(/^-+/g, ""); + return "reportField-" + (suffix ? suffix : ""); + } + function peg$f4() { autocomplete = null; } + function peg$f5(k) { + nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); + return k; + } + function peg$f6(parts, empty) { + const ends = location(); + const value = parts.flat().filter(Boolean); // Filter out undefined values returned by the predicate + if (empty) { + value.push(""); + } + let count = ends.start.offset; + const result = []; + value.forEach((filter) => { + let word = filter; + if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { + word = word.slice(1, -1); + } + result.push({ + value: word, + start: count, + length: filter.length, }); - return result; - }; - var peg$f7 = function() { return "date"; }; - var peg$f8 = function() { return "amount"; }; - var peg$f9 = function() { return "merchant"; }; - var peg$f10 = function() { return "description"; }; - var peg$f11 = function() { return "reportID"; }; - var peg$f12 = function() { return "keyword"; }; - var peg$f13 = function() { return "in"; }; - var peg$f14 = function() { return "currency"; }; - var peg$f15 = function() { return "groupCurrency"; }; - var peg$f16 = function() { return "tag"; }; - var peg$f17 = function() { return "category"; }; - var peg$f18 = function() { return "to"; }; - var peg$f19 = function() { return "exporter"; }; - var peg$f20 = function() { return "payer"; }; - var peg$f21 = function() { return "taxRate"; }; - var peg$f22 = function() { return "cardID"; }; - var peg$f23 = function() { return "from"; }; - var peg$f24 = function() {return "attendee"}; - var peg$f25 = function() { return "expenseType"; }; - var peg$f26 = function() { return "withdrawalType"; }; - var peg$f27 = function() { return "withdrawalID"; }; - var peg$f28 = function() { return "billable"; }; - var peg$f29 = function() { return "reimbursable"; }; - var peg$f30 = function() { return "type"; }; - var peg$f31 = function() { return "status"; }; - var peg$f32 = function() { return "sortBy"; }; - var peg$f33 = function() { return "sortOrder"; }; - var peg$f34 = function() { return "policyID"; }; - var peg$f35 = function() { return "submitted"; }; - var peg$f36 = function() { return "approved"; }; - var peg$f37 = function() { return "paid"; }; - var peg$f38 = function() { return "exported"; }; - var peg$f39 = function() { return "posted"; }; - var peg$f40 = function() { return "withdrawn"; }; - var peg$f41 = function() { return "groupBy"; }; - var peg$f42 = function() { return "limit"; }; - var peg$f43 = function() { return "feed"; }; - var peg$f44 = function() { return "title"; }; - var peg$f45 = function() { return "assignee"; }; - var peg$f46 = function() { return "createdBy"; }; - var peg$f47 = function() { return "action"; }; - var peg$f48 = function() {return "total"; }; - var peg$f49 = function() {return "has"; }; - var peg$f50 = function() {return "is"; }; - var peg$f51 = function() {return "purchaseAmount"}; - var peg$f52 = function() {return "purchaseCurrency"}; - var peg$f53 = function() { - isColumnsContext = true; - return "columns"; -}; - var peg$f54 = function() {return "view"}; - var peg$f55 = function() { return isColumnsContext; }; - var peg$f56 = function() { return "perDiem"; }; - var peg$f57 = function() { return "drafts"; }; - var peg$f58 = function() { return "originalamount"; }; - var peg$f59 = function() { return "taxAmount"; }; - var peg$f60 = function() { return "taxrate"; }; - var peg$f61 = function() { return "policyname"; }; - var peg$f62 = function() { return "withdrawalID"; }; - var peg$f63 = function() { return "bankAccount"; }; - var peg$f64 = function() { return "reportID"; }; - var peg$f65 = function() { return "base62ReportID"; }; - var peg$f66 = function() { return "exportedto"; }; - var peg$f67 = function() { return "exportedTo"; }; - var peg$f68 = function() { return "exportedTo"; }; - var peg$f69 = function() { return "exchangeRate"; }; - var peg$f70 = function() { return "reimbursableTotal"; }; - var peg$f71 = function() { return "nonReimbursableTotal"; }; - var peg$f72 = function() { return "groupFrom"; }; - var peg$f73 = function() { return "groupExpenses"; }; - var peg$f74 = function() { return "groupTotal"; }; - var peg$f75 = function() { return "groupCard"; }; - var peg$f76 = function() { return "groupFeed"; }; - var peg$f77 = function() { return "groupBankAccount"; }; - var peg$f78 = function() { return "groupWithdrawn"; }; - var peg$f79 = function() { return "groupWithdrawalID"; }; - var peg$f80 = function() { return "groupCategory"; }; - var peg$f81 = function() { return "groupTag"; }; - var peg$f82 = function() { return "groupMerchant"; }; - var peg$f83 = function() { return "groupmonth"; }; - var peg$f84 = function() { return "groupweek"; }; - var peg$f85 = function() { return "groupyear"; }; - var peg$f86 = function() { return "groupquarter"; }; - var peg$f87 = function() { return "eq"; }; - var peg$f88 = function() { return "neq"; }; - var peg$f89 = function() { return "gte"; }; - var peg$f90 = function() { return "gt"; }; - var peg$f91 = function() { return "lte"; }; - var peg$f92 = function() { return "lt"; }; - var peg$f93 = function(o) { - if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found - } - isColumnsContext = false; - return o; - }; - var peg$f94 = function(chars) { return chars.join("").trim(); }; - var peg$f95 = function() { - isColumnsContext = false; - return "and"; -}; - var peg$f96 = function() { return expectingNestedQuote; }; - var peg$f97 = function(start, inner, end) { //handle no-breaking space - return [...start, '"', ...inner, '"', ...end].join(""); - }; - var peg$f98 = function(start) {return "“"}; - var peg$f99 = function(start) {return "”"}; - var peg$f100 = function(start) {return "\""}; - var peg$f101 = function(start, inner, end) { + count += filter.length + 1; + }); + return result; + } + function peg$f7() { return "date"; } + function peg$f8() { return "amount"; } + function peg$f9() { return "merchant"; } + function peg$f10() { return "description"; } + function peg$f11() { return "reportID"; } + function peg$f12() { return "in"; } + function peg$f13() { return "currency"; } + function peg$f14() { return "groupCurrency"; } + function peg$f15() { return "tag"; } + function peg$f16() { return "category"; } + function peg$f17() { return "to"; } + function peg$f18() { return "exporter"; } + function peg$f19() { return "payer"; } + function peg$f20() { return "taxRate"; } + function peg$f21() { return "cardID"; } + function peg$f22() { return "from"; } + function peg$f23() {return "attendee" } + function peg$f24() { return "expenseType"; } + function peg$f25() { return "withdrawalType"; } + function peg$f26() { return "withdrawalID"; } + function peg$f27() { return "billable"; } + function peg$f28() { return "reimbursable"; } + function peg$f29() { return "type"; } + function peg$f30() { return "status"; } + function peg$f31() { return "policyID"; } + function peg$f32() { return "submitted"; } + function peg$f33() { return "approved"; } + function peg$f34() { return "paid"; } + function peg$f35() { return "exported"; } + function peg$f36() { return "posted"; } + function peg$f37() { return "withdrawn"; } + function peg$f38() { return "groupBy"; } + function peg$f39() { return "limit"; } + function peg$f40() { return "feed"; } + function peg$f41() { return "title"; } + function peg$f42() { return "assignee"; } + function peg$f43() { return "createdBy"; } + function peg$f44() { return "action"; } + function peg$f45() {return "total"; } + function peg$f46() {return "has"; } + function peg$f47() {return "is"; } + function peg$f48() {return "purchaseAmount" } + function peg$f49() {return "purchaseCurrency" } + function peg$f50() { + isColumnsContext = true; + return "columns"; + } + function peg$f51() {return "view" } + function peg$f52() { return "exportedTo"; } + function peg$f53() { return "exportedTo"; } + function peg$f54() { return "contains"; } + function peg$f55() { return "eq"; } + function peg$f56() { return "neq"; } + function peg$f57() { return "gte"; } + function peg$f58() { return "gt"; } + function peg$f59() { return "lte"; } + function peg$f60() { return "lt"; } + function peg$f61(o) { + if (nameOperator) { + expectingNestedQuote = (o === "eq" || o === "contains"); + } + isColumnsContext = false; + return o; + } + function peg$f62(chars) { return chars.join("").trim(); } + function peg$f63() { + isColumnsContext = false; + return "and"; + } + function peg$f64() { return expectingNestedQuote; } + function peg$f65(start, inner, end) { //handle no-breaking space + return [...start, '"', ...inner, '"', ...end].join(""); + } + function peg$f66(start) {return "“" } + function peg$f67(start) {return "”" } + function peg$f68(start) {return "\"" } + function peg$f69(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); -}; - var peg$currPos = options.peg$currPos | 0; - var peg$savedPos = peg$currPos; - var peg$posDetailsCache = [{ line: 1, column: 1 }]; - var peg$maxFailPos = peg$currPos; - var peg$maxFailExpected = options.peg$maxFailExpected || []; - var peg$silentFails = options.peg$silentFails | 0; + } + let peg$currPos = options.peg$currPos | 0; + let peg$savedPos = peg$currPos; + const peg$posDetailsCache = [{ line: 1, column: 1 }]; + let peg$maxFailPos = peg$currPos; + let peg$maxFailExpected = options.peg$maxFailExpected || []; + let peg$silentFails = options.peg$silentFails | 0; - var peg$result; + let peg$result; if (options.startRule) { if (!(options.startRule in peg$startRuleFunctions)) { @@ -624,7 +520,7 @@ function peg$parse(input, options) { return { source: peg$source, start: peg$savedPos, - end: peg$currPos + end: peg$currPos, }; } @@ -652,12 +548,20 @@ function peg$parse(input, options) { throw peg$buildSimpleError(message, location); } + function peg$getUnicode(pos = peg$currPos) { + const cp = input.codePointAt(pos); + if (cp === undefined) { + return ""; + } + return String.fromCodePoint(cp); + } + function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; + return { type: "literal", text, ignoreCase }; } - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + function peg$classExpectation(parts, inverted, ignoreCase, unicode) { + return { type: "class", parts, inverted, ignoreCase, unicode }; } function peg$anyExpectation() { @@ -669,12 +573,12 @@ function peg$parse(input, options) { } function peg$otherExpectation(description) { - return { type: "other", description: description }; + return { type: "other", description }; } function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos]; - var p; + let details = peg$posDetailsCache[pos]; + let p; if (details) { return details; @@ -689,7 +593,7 @@ function peg$parse(input, options) { details = peg$posDetailsCache[p]; details = { line: details.line, - column: details.column + column: details.column, }; while (p < pos) { @@ -710,21 +614,21 @@ function peg$parse(input, options) { } function peg$computeLocation(startPos, endPos, offset) { - var startPosDetails = peg$computePosDetails(startPos); - var endPosDetails = peg$computePosDetails(endPos); + const startPosDetails = peg$computePosDetails(startPos); + const endPosDetails = peg$computePosDetails(endPos); - var res = { + const res = { source: peg$source, start: { offset: startPos, line: startPosDetails.line, - column: startPosDetails.column + column: startPosDetails.column, }, end: { offset: endPos, line: endPosDetails.line, - column: endPosDetails.column - } + column: endPosDetails.column, + }, }; if (offset && peg$source && (typeof peg$source.offset === "function")) { res.start = peg$source.offset(res.start); @@ -758,7 +662,7 @@ function peg$parse(input, options) { } function peg$parsequery() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -771,7 +675,7 @@ function peg$parse(input, options) { } function peg$parsefilterList() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = []; @@ -796,7 +700,7 @@ function peg$parse(input, options) { } function peg$parsefilter() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parsedefaultFilter(); @@ -814,7 +718,7 @@ function peg$parse(input, options) { } function peg$parsedefaultFilter() { - var s0, s1, s2, s3, s4, s5, s6, s7; + let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = peg$parse_(); @@ -853,12 +757,12 @@ function peg$parse(input, options) { } function peg$parsereportFieldDynamic() { - var s0, s1, s2, s3, s4, s5, s6, s7; + let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c1) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e1); } @@ -866,7 +770,7 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c2) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e2); } @@ -974,7 +878,7 @@ function peg$parse(input, options) { } function peg$parsefreeTextFilter() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -1001,7 +905,7 @@ function peg$parse(input, options) { } function peg$parseautocompleteKey() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -1160,7 +1064,7 @@ function peg$parse(input, options) { } function peg$parsefilterKey() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parseautocompleteKey(); @@ -1174,7 +1078,7 @@ function peg$parse(input, options) { } function peg$parseidentifier() { - var s0, s1, s2, s3, s4; + let s0, s1, s2, s3, s4; s0 = peg$currPos; s1 = peg$currPos; @@ -1236,12 +1140,12 @@ function peg$parse(input, options) { } function peg$parsedate() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c4) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e7); } @@ -1256,12 +1160,12 @@ function peg$parse(input, options) { } function peg$parseamount() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c5) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e8); } @@ -1276,12 +1180,12 @@ function peg$parse(input, options) { } function peg$parsemerchant() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c6) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e9); } @@ -1296,12 +1200,12 @@ function peg$parse(input, options) { } function peg$parsedescription() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c7) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e10); } @@ -1316,11 +1220,11 @@ function peg$parse(input, options) { } function peg$parsereportID() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 8); if (s0.toLowerCase() === peg$c8) { - peg$currPos += 8; + peg$currPos += (8); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e11); } @@ -1329,7 +1233,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c9) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e12); } @@ -1344,13 +1248,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsekeyword() { - var s0, s1; + function peg$parsein() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 7); + s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c10) { - peg$currPos += 7; + peg$currPos += (2); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e13); } @@ -1364,13 +1268,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsein() { - var s0, s1; + function peg$parsecurrency() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 2); + s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c11) { - peg$currPos += 2; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e14); } @@ -1384,48 +1288,28 @@ function peg$parse(input, options) { return s0; } - function peg$parsecurrency() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c12) { - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e15); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f14(); - } - s0 = s1; - - return s0; - } - function peg$parsegroupCurrency() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 13) === peg$c13) { - s0 = peg$c13; + if (input.substr(peg$currPos, 13) === peg$c12) { + s0 = peg$c12; peg$currPos += 13; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e16); } + if (peg$silentFails === 0) { peg$fail(peg$e15); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c14) { - peg$currPos += 14; + if (s1.toLowerCase() === peg$c13) { + peg$currPos += (14); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e17); } + if (peg$silentFails === 0) { peg$fail(peg$e16); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f15(); + s1 = peg$f14(); } s0 = s1; } @@ -1434,19 +1318,19 @@ function peg$parse(input, options) { } function peg$parsetag() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c15) { - peg$currPos += 3; + if (s1.toLowerCase() === peg$c14) { + peg$currPos += (3); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e18); } + if (peg$silentFails === 0) { peg$fail(peg$e17); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f16(); + s1 = peg$f15(); } s0 = s1; @@ -1454,19 +1338,19 @@ function peg$parse(input, options) { } function peg$parsecategory() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c16) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c15) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } + if (peg$silentFails === 0) { peg$fail(peg$e18); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f17(); + s1 = peg$f16(); } s0 = s1; @@ -1474,19 +1358,19 @@ function peg$parse(input, options) { } function peg$parseto() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); - if (s1.toLowerCase() === peg$c17) { - peg$currPos += 2; + if (s1.toLowerCase() === peg$c16) { + peg$currPos += (2); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e19); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f18(); + s1 = peg$f17(); } s0 = s1; @@ -1494,19 +1378,19 @@ function peg$parse(input, options) { } function peg$parseexporter() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c18) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c17) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } + if (peg$silentFails === 0) { peg$fail(peg$e20); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f19(); + s1 = peg$f18(); } s0 = s1; @@ -1514,19 +1398,19 @@ function peg$parse(input, options) { } function peg$parsepayer() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c19) { - peg$currPos += 5; + if (s1.toLowerCase() === peg$c18) { + peg$currPos += (5); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f20(); + s1 = peg$f19(); } s0 = s1; @@ -1534,27 +1418,27 @@ function peg$parse(input, options) { } function peg$parsetaxRate() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 7); - if (s0.toLowerCase() === peg$c20) { - peg$currPos += 7; + if (s0.toLowerCase() === peg$c19) { + peg$currPos += (7); } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c21) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c20) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } + if (peg$silentFails === 0) { peg$fail(peg$e23); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f21(); + s1 = peg$f20(); } s0 = s1; } @@ -1563,27 +1447,27 @@ function peg$parse(input, options) { } function peg$parsecardID() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 6) === peg$c22) { - s0 = peg$c22; + if (input.substr(peg$currPos, 6) === peg$c21) { + s0 = peg$c21; peg$currPos += 6; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e25); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c23) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c22) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e25); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f22(); + s1 = peg$f21(); } s0 = s1; } @@ -1592,19 +1476,19 @@ function peg$parse(input, options) { } function peg$parsefrom() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c24) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c23) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e27); } + if (peg$silentFails === 0) { peg$fail(peg$e26); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f23(); + s1 = peg$f22(); } s0 = s1; @@ -1612,19 +1496,19 @@ function peg$parse(input, options) { } function peg$parseattendee() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c25) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c24) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f24(); + s1 = peg$f23(); } s0 = s1; @@ -1632,27 +1516,27 @@ function peg$parse(input, options) { } function peg$parseexpenseType() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 11) === peg$c26) { - s0 = peg$c26; + if (input.substr(peg$currPos, 11) === peg$c25) { + s0 = peg$c25; peg$currPos += 11; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } + if (peg$silentFails === 0) { peg$fail(peg$e28); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c27) { - peg$currPos += 12; + if (s1.toLowerCase() === peg$c26) { + peg$currPos += (12); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e30); } + if (peg$silentFails === 0) { peg$fail(peg$e29); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f25(); + s1 = peg$f24(); } s0 = s1; } @@ -1661,27 +1545,27 @@ function peg$parse(input, options) { } function peg$parsewithdrawalType() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 14) === peg$c28) { - s0 = peg$c28; + if (input.substr(peg$currPos, 14) === peg$c27) { + s0 = peg$c27; peg$currPos += 14; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e31); } + if (peg$silentFails === 0) { peg$fail(peg$e30); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c29) { - peg$currPos += 15; + if (s1.toLowerCase() === peg$c28) { + peg$currPos += (15); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e32); } + if (peg$silentFails === 0) { peg$fail(peg$e31); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f26(); + s1 = peg$f25(); } s0 = s1; } @@ -1690,27 +1574,27 @@ function peg$parse(input, options) { } function peg$parsewithdrawalID() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 12); - if (s0.toLowerCase() === peg$c30) { - peg$currPos += 12; + if (s0.toLowerCase() === peg$c29) { + peg$currPos += (12); } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e33); } + if (peg$silentFails === 0) { peg$fail(peg$e32); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c31) { - peg$currPos += 13; + if (s1.toLowerCase() === peg$c30) { + peg$currPos += (13); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e34); } + if (peg$silentFails === 0) { peg$fail(peg$e33); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f27(); + s1 = peg$f26(); } s0 = s1; } @@ -1719,19 +1603,19 @@ function peg$parse(input, options) { } function peg$parsebillable() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c32) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c31) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e35); } + if (peg$silentFails === 0) { peg$fail(peg$e34); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f28(); + s1 = peg$f27(); } s0 = s1; @@ -1739,19 +1623,19 @@ function peg$parse(input, options) { } function peg$parsereimbursable() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c33) { - peg$currPos += 12; + if (s1.toLowerCase() === peg$c32) { + peg$currPos += (12); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e36); } + if (peg$silentFails === 0) { peg$fail(peg$e35); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f29(); + s1 = peg$f28(); } s0 = s1; @@ -1759,19 +1643,19 @@ function peg$parse(input, options) { } function peg$parsetype() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c34) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c33) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e37); } + if (peg$silentFails === 0) { peg$fail(peg$e36); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f30(); + s1 = peg$f29(); } s0 = s1; @@ -1779,105 +1663,47 @@ function peg$parse(input, options) { } function peg$parsestatus() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c35) { - peg$currPos += 6; + if (s1.toLowerCase() === peg$c34) { + peg$currPos += (6); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e38); } + if (peg$silentFails === 0) { peg$fail(peg$e37); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f31(); + s1 = peg$f30(); } s0 = s1; return s0; } - function peg$parsesortBy() { - var s0, s1; - - if (input.substr(peg$currPos, 6) === peg$c36) { - s0 = peg$c36; - peg$currPos += 6; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e39); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c37) { - peg$currPos += 7; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e40); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f32(); - } - s0 = s1; - } - - return s0; - } - - function peg$parsesortOrder() { - var s0, s1; - - if (input.substr(peg$currPos, 9) === peg$c38) { - s0 = peg$c38; - peg$currPos += 9; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e41); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c39) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e42); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f33(); - } - s0 = s1; - } - - return s0; - } - function peg$parsepolicyID() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 8) === peg$c40) { - s0 = peg$c40; + if (input.substr(peg$currPos, 8) === peg$c35) { + s0 = peg$c35; peg$currPos += 8; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e43); } + if (peg$silentFails === 0) { peg$fail(peg$e38); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c41) { - peg$currPos += 9; + if (s1.toLowerCase() === peg$c36) { + peg$currPos += (9); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e44); } + if (peg$silentFails === 0) { peg$fail(peg$e39); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f34(); + s1 = peg$f31(); } s0 = s1; } @@ -1886,19 +1712,19 @@ function peg$parse(input, options) { } function peg$parsesubmitted() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c42) { - peg$currPos += 9; + if (s1.toLowerCase() === peg$c37) { + peg$currPos += (9); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e45); } + if (peg$silentFails === 0) { peg$fail(peg$e40); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f35(); + s1 = peg$f32(); } s0 = s1; @@ -1906,19 +1732,19 @@ function peg$parse(input, options) { } function peg$parseapproved() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c43) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c38) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e46); } + if (peg$silentFails === 0) { peg$fail(peg$e41); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f36(); + s1 = peg$f33(); } s0 = s1; @@ -1926,19 +1752,19 @@ function peg$parse(input, options) { } function peg$parsepaid() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c44) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c39) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e47); } + if (peg$silentFails === 0) { peg$fail(peg$e42); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f37(); + s1 = peg$f34(); } s0 = s1; @@ -1946,19 +1772,19 @@ function peg$parse(input, options) { } function peg$parseexported() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c45) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c40) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e48); } + if (peg$silentFails === 0) { peg$fail(peg$e43); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f38(); + s1 = peg$f35(); } s0 = s1; @@ -1966,19 +1792,19 @@ function peg$parse(input, options) { } function peg$parseposted() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c46) { - peg$currPos += 6; + if (s1.toLowerCase() === peg$c41) { + peg$currPos += (6); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e49); } + if (peg$silentFails === 0) { peg$fail(peg$e44); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f39(); + s1 = peg$f36(); } s0 = s1; @@ -1986,19 +1812,19 @@ function peg$parse(input, options) { } function peg$parsewithdrawn() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c47) { - peg$currPos += 9; + if (s1.toLowerCase() === peg$c42) { + peg$currPos += (9); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e50); } + if (peg$silentFails === 0) { peg$fail(peg$e45); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f40(); + s1 = peg$f37(); } s0 = s1; @@ -2006,27 +1832,27 @@ function peg$parse(input, options) { } function peg$parsegroupBy() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 7); - if (s0.toLowerCase() === peg$c48) { - peg$currPos += 7; + if (s0.toLowerCase() === peg$c43) { + peg$currPos += (7); } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e51); } + if (peg$silentFails === 0) { peg$fail(peg$e46); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c49) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c44) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e52); } + if (peg$silentFails === 0) { peg$fail(peg$e47); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f41(); + s1 = peg$f38(); } s0 = s1; } @@ -2035,19 +1861,19 @@ function peg$parse(input, options) { } function peg$parselimit() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c50) { - peg$currPos += 5; + if (s1.toLowerCase() === peg$c45) { + peg$currPos += (5); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e53); } + if (peg$silentFails === 0) { peg$fail(peg$e48); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f42(); + s1 = peg$f39(); } s0 = s1; @@ -2055,19 +1881,19 @@ function peg$parse(input, options) { } function peg$parsefeed() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c51) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c46) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e54); } + if (peg$silentFails === 0) { peg$fail(peg$e49); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f43(); + s1 = peg$f40(); } s0 = s1; @@ -2075,19 +1901,19 @@ function peg$parse(input, options) { } function peg$parsetitle() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c52) { - peg$currPos += 5; + if (s1.toLowerCase() === peg$c47) { + peg$currPos += (5); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e55); } + if (peg$silentFails === 0) { peg$fail(peg$e50); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f44(); + s1 = peg$f41(); } s0 = s1; @@ -2095,19 +1921,19 @@ function peg$parse(input, options) { } function peg$parseassignee() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c53) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c48) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e56); } + if (peg$silentFails === 0) { peg$fail(peg$e51); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f45(); + s1 = peg$f42(); } s0 = s1; @@ -2115,27 +1941,27 @@ function peg$parse(input, options) { } function peg$parsecreatedBy() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 9); - if (s0.toLowerCase() === peg$c54) { - peg$currPos += 9; + if (s0.toLowerCase() === peg$c49) { + peg$currPos += (9); } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e57); } + if (peg$silentFails === 0) { peg$fail(peg$e52); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c55) { - peg$currPos += 10; + if (s1.toLowerCase() === peg$c50) { + peg$currPos += (10); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e58); } + if (peg$silentFails === 0) { peg$fail(peg$e53); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f46(); + s1 = peg$f43(); } s0 = s1; } @@ -2144,19 +1970,19 @@ function peg$parse(input, options) { } function peg$parseaction() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c56) { - peg$currPos += 6; + if (s1.toLowerCase() === peg$c51) { + peg$currPos += (6); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e59); } + if (peg$silentFails === 0) { peg$fail(peg$e54); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f47(); + s1 = peg$f44(); } s0 = s1; @@ -2164,1438 +1990,204 @@ function peg$parse(input, options) { } function peg$parsetotal() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c57) { - peg$currPos += 5; + if (s1.toLowerCase() === peg$c52) { + peg$currPos += (5); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e60); } + if (peg$silentFails === 0) { peg$fail(peg$e55); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f48(); - } - s0 = s1; - - return s0; - } - - function peg$parsehas() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c58) { - peg$currPos += 3; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e61); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f49(); - } - s0 = s1; - - return s0; - } - - function peg$parseis() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 2); - if (s1.toLowerCase() === peg$c59) { - peg$currPos += 2; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e62); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f50(); - } - s0 = s1; - - return s0; - } - - function peg$parsepurchaseAmount() { - var s0, s1; - - s0 = input.substr(peg$currPos, 14); - if (s0.toLowerCase() === peg$c60) { - peg$currPos += 14; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e63); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c61) { - peg$currPos += 15; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e64); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f51(); - } - s0 = s1; - } - - return s0; - } - - function peg$parsepurchaseCurrency() { - var s0, s1; - - s0 = input.substr(peg$currPos, 16); - if (s0.toLowerCase() === peg$c62) { - peg$currPos += 16; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e65); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c63) { - peg$currPos += 17; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e66); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f52(); - } - s0 = s1; - } - - return s0; - } - - function peg$parsecolumns() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c64) { - peg$currPos += 7; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e67); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f53(); - } - s0 = s1; - - return s0; - } - - function peg$parseview() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c65) { - peg$currPos += 4; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e68); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f54(); - } - s0 = s1; - - return s0; - } - - function peg$parsevalues() { - var s0, s1, s2; - - s0 = peg$currPos; - peg$savedPos = peg$currPos; - s1 = peg$f55(); - if (s1) { - s1 = undefined; - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - s2 = peg$parsecolumnsValues(); - if (s2 !== peg$FAILED) { - s1 = [s1, s2]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$parseperDiem(); - if (s0 === peg$FAILED) { - s0 = peg$parsedraft(); - } - } - - return s0; - } - - function peg$parsecolumnsValues() { - var s0; - - s0 = peg$parseoriginalAmount(); - if (s0 === peg$FAILED) { - s0 = peg$parsetaxRateValue(); - if (s0 === peg$FAILED) { - s0 = peg$parsetaxAmount(); - if (s0 === peg$FAILED) { - s0 = peg$parsepolicyName(); - if (s0 === peg$FAILED) { - s0 = peg$parsewithdrawalId(); - if (s0 === peg$FAILED) { - s0 = peg$parsebankAccount(); - if (s0 === peg$FAILED) { - s0 = peg$parsereportId(); - if (s0 === peg$FAILED) { - s0 = peg$parsebase62ReportId(); - if (s0 === peg$FAILED) { - s0 = peg$parseexportedToColumnValue(); - if (s0 === peg$FAILED) { - s0 = peg$parseexchangeRate(); - if (s0 === peg$FAILED) { - s0 = peg$parsereimbursableTotal(); - if (s0 === peg$FAILED) { - s0 = peg$parsenonReimbursableTotal(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupFrom(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupExpenses(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupTotal(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupCard(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupFeed(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupBankAccount(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupWithdrawn(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupWithdrawalId(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupCategory(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupTag(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupMerchant(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupMonth(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupWeek(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupYear(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupQuarter(); - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - - return s0; - } - - function peg$parseperDiem() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c66) { - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e69); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f56(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsedraft() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c67) { - peg$currPos += 6; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e70); } - } - if (s1 === peg$FAILED) { - s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c68) { - peg$currPos += 5; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e71); } - } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f57(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseoriginalAmount() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c69) { - peg$currPos += 15; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e72); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f58(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsetaxAmount() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c70) { - peg$currPos += 3; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e73); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f59(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsetaxRateValue() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c21) { - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f60(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsepolicyName() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c71) { - peg$currPos += 11; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e74); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f61(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsewithdrawalId() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c31) { - peg$currPos += 13; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e34); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f62(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsebankAccount() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c72) { - peg$currPos += 12; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e75); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f63(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsereportId() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c73) { - peg$currPos += 14; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e76); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f64(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsebase62ReportId() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c9) { - peg$currPos += 9; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e12); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f65(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseexportedToColumnValue() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c74) { - peg$currPos += 11; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e77); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f66(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseexportedToKeyEnd() { - var s0, s1, s2; - - s0 = peg$parsewordBoundary(); - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parse_(); - s2 = input.charAt(peg$currPos); - if (peg$r1.test(s2)) { - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } - } - if (s2 !== peg$FAILED) { - s1 = [s1, s2]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseexportedTo() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c75) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e79); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parseexportedToKeyEnd(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f67(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c74) { - peg$currPos += 11; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e77); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parseexportedToKeyEnd(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f68(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseexchangeRate() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c76) { - peg$currPos += 13; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f69(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsereimbursableTotal() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c77) { - peg$currPos += 18; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e81); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f70(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsenonReimbursableTotal() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 22); - if (s1.toLowerCase() === peg$c78) { - peg$currPos += 22; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e82); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f71(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupFrom() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c79) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e83); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f72(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupExpenses() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c80) { - peg$currPos += 14; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e84); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f73(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupTotal() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c81) { - peg$currPos += 11; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e85); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f74(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupCard() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c82) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e86); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f75(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupFeed() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c83) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e87); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f76(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupBankAccount() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c84) { - peg$currPos += 18; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e88); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f77(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupWithdrawn() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c85) { - peg$currPos += 15; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e89); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f78(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupWithdrawalId() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 19); - if (s1.toLowerCase() === peg$c86) { - peg$currPos += 19; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e90); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f79(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s1 = peg$f45(); } + s0 = s1; return s0; } - function peg$parsegroupCategory() { - var s0, s1, s2, s3; + function peg$parsehas() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c87) { - peg$currPos += 14; + s1 = input.substr(peg$currPos, 3); + if (s1.toLowerCase() === peg$c53) { + peg$currPos += (3); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e91); } + if (peg$silentFails === 0) { peg$fail(peg$e56); } } if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f80(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + peg$savedPos = s0; + s1 = peg$f46(); } + s0 = s1; return s0; } - function peg$parsegroupTag() { - var s0, s1, s2, s3; + function peg$parseis() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c88) { - peg$currPos += 9; + s1 = input.substr(peg$currPos, 2); + if (s1.toLowerCase() === peg$c54) { + peg$currPos += (2); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e92); } + if (peg$silentFails === 0) { peg$fail(peg$e57); } } if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f81(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + peg$savedPos = s0; + s1 = peg$f47(); } + s0 = s1; return s0; } - function peg$parsegroupMerchant() { - var s0, s1, s2, s3; + function peg$parsepurchaseAmount() { + let s0, s1; - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c89) { - peg$currPos += 14; + s0 = input.substr(peg$currPos, 14); + if (s0.toLowerCase() === peg$c55) { + peg$currPos += (14); } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e93); } + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e58); } } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 15); + if (s1.toLowerCase() === peg$c56) { + peg$currPos += (15); } else { - s2 = peg$FAILED; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e59); } } - if (s2 !== peg$FAILED) { + if (s1 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f82(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s1 = peg$f48(); } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s0 = s1; } return s0; } - function peg$parsegroupMonth() { - var s0, s1, s2, s3; + function peg$parsepurchaseCurrency() { + let s0, s1; - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c90) { - peg$currPos += 11; + s0 = input.substr(peg$currPos, 16); + if (s0.toLowerCase() === peg$c57) { + peg$currPos += (16); } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e94); } + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e60); } } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 17); + if (s1.toLowerCase() === peg$c58) { + peg$currPos += (17); } else { - s2 = peg$FAILED; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e61); } } - if (s2 !== peg$FAILED) { + if (s1 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f83(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s1 = peg$f49(); } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s0 = s1; } return s0; } - function peg$parsegroupWeek() { - var s0, s1, s2, s3; + function peg$parsecolumns() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c91) { - peg$currPos += 10; + s1 = input.substr(peg$currPos, 7); + if (s1.toLowerCase() === peg$c59) { + peg$currPos += (7); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e95); } + if (peg$silentFails === 0) { peg$fail(peg$e62); } } if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f84(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + peg$savedPos = s0; + s1 = peg$f50(); } + s0 = s1; return s0; } - function peg$parsegroupYear() { - var s0, s1, s2, s3; + function peg$parseview() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c92) { - peg$currPos += 10; + s1 = input.substr(peg$currPos, 4); + if (s1.toLowerCase() === peg$c60) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e96); } + if (peg$silentFails === 0) { peg$fail(peg$e63); } } if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; + peg$savedPos = s0; + s1 = peg$f51(); + } + s0 = s1; + + return s0; + } + + function peg$parseexportedToKeyEnd() { + let s0, s1, s2; + + s0 = peg$parsewordBoundary(); + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = input.charAt(peg$currPos); + if (peg$r1.test(s2)) { + peg$currPos++; } else { s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e64); } } if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f85(); + s1 = [s1, s2]; + s0 = s1; } else { peg$currPos = s0; s0 = peg$FAILED; } - } else { - peg$currPos = s0; - s0 = peg$FAILED; } return s0; } - function peg$parsegroupQuarter() { - var s0, s1, s2, s3; + function peg$parseexportedTo() { + let s0, s1, s2, s3; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c93) { - peg$currPos += 13; + s1 = input.substr(peg$currPos, 10); + if (s1.toLowerCase() === peg$c61) { + peg$currPos += (10); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e97); } + if (peg$silentFails === 0) { peg$fail(peg$e65); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; peg$silentFails++; - s3 = peg$parsewordBoundary(); + s3 = peg$parseexportedToKeyEnd(); peg$silentFails--; if (s3 !== peg$FAILED) { peg$currPos = s2; @@ -3605,7 +2197,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f86(); + s0 = peg$f52(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3614,97 +2206,144 @@ function peg$parse(input, options) { peg$currPos = s0; s0 = peg$FAILED; } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c62) { + peg$currPos += (11); + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e66); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parseexportedToKeyEnd(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f53(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } return s0; } function peg$parseoperator() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r1.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c63) { + s1 = peg$c63; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } + if (peg$silentFails === 0) { peg$fail(peg$e68); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f87(); + s1 = peg$f54(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c94) { - s1 = peg$c94; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r1.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e64); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f88(); + s1 = peg$f55(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c95) { - s1 = peg$c95; + if (input.substr(peg$currPos, 2) === peg$c64) { + s1 = peg$c64; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e69); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f89(); + s1 = peg$f56(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c96; - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c65) { + s1 = peg$c65; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e70); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f90(); + s1 = peg$f57(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c97) { - s1 = peg$c97; - peg$currPos += 2; + if (input.charCodeAt(peg$currPos) === 62) { + s1 = peg$c66; + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e71); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f91(); + s1 = peg$f58(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c98; - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c67) { + s1 = peg$c67; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e72); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f92(); + s1 = peg$f59(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c68; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e73); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f60(); + } + s0 = s1; + } } } } @@ -3713,20 +2352,20 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e67); } } return s0; } function peg$parsefilterOperator() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f93(s1); + s1 = peg$f61(s1); } s0 = s1; @@ -3734,7 +2373,7 @@ function peg$parse(input, options) { } function peg$parsealphanumeric() { - var s0, s1, s2; + let s0, s1, s2; peg$silentFails++; s0 = peg$currPos; @@ -3744,7 +2383,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -3754,7 +2393,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } } } else { @@ -3762,32 +2401,32 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f94(s1); + s1 = peg$f62(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e74); } } return s0; } function peg$parselogicalAnd() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f95(); + s1 = peg$f63(); s0 = s1; return s0; } function peg$parse_() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = []; @@ -3796,7 +2435,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e76); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -3805,22 +2444,20 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e76); } } } peg$silentFails--; - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } return s0; } function peg$parsequotedString() { - var s0, s1, s2; + let s0, s1, s2; s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f96(); + s1 = peg$f64(); if (s1) { s1 = undefined; } else { @@ -3847,7 +2484,7 @@ function peg$parse(input, options) { } function peg$parsesimpleQuotedString() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -3857,7 +2494,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3866,7 +2503,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } } s2 = input.charAt(peg$currPos); @@ -3874,7 +2511,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3883,7 +2520,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -3892,7 +2529,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } } s4 = input.charAt(peg$currPos); @@ -3900,7 +2537,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s4 !== peg$FAILED) { s5 = []; @@ -3909,7 +2546,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -3918,11 +2555,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } } peg$savedPos = s0; - s0 = peg$f97(s1, s3, s5); + s0 = peg$f65(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3934,14 +2571,14 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e77); } } return s0; } function peg$parsenestedQuotedString() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -3951,7 +2588,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3960,7 +2597,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } } s2 = input.charAt(peg$currPos); @@ -3968,7 +2605,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3977,7 +2614,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3993,15 +2630,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c99; + s6 = peg$c69; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e81); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f98(s1); + s4 = peg$f66(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4024,15 +2661,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c100; + s6 = peg$c70; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e82); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f99(s1); + s4 = peg$f67(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4055,15 +2692,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c101; + s6 = peg$c71; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e83); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f100(s1); + s4 = peg$f68(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4082,7 +2719,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4098,15 +2735,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c99; + s6 = peg$c69; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e81); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f98(s1); + s4 = peg$f66(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4129,15 +2766,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c100; + s6 = peg$c70; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e82); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f99(s1); + s4 = peg$f67(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4160,15 +2797,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c101; + s6 = peg$c71; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e83); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f100(s1); + s4 = peg$f68(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4184,7 +2821,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f101(s1, s3, s4); + s0 = peg$f69(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4196,14 +2833,14 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e77); } } return s0; } function peg$parseclosingQuote() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.charAt(peg$currPos); @@ -4211,7 +2848,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4240,7 +2877,7 @@ function peg$parse(input, options) { } function peg$parsevalidClosingCondition() { - var s0, s1, s2, s3, s4; + let s0, s1, s2, s3, s4; s0 = peg$currPos; s1 = []; @@ -4249,7 +2886,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e84); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4258,7 +2895,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e84); } } } s2 = []; @@ -4267,7 +2904,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e116); } + if (peg$silentFails === 0) { peg$fail(peg$e85); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -4276,7 +2913,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e116); } + if (peg$silentFails === 0) { peg$fail(peg$e85); } } } s3 = []; @@ -4285,7 +2922,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e117); } + if (peg$silentFails === 0) { peg$fail(peg$e86); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -4294,7 +2931,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e117); } + if (peg$silentFails === 0) { peg$fail(peg$e86); } } } s4 = peg$parseoperator(); @@ -4313,7 +2950,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e118); } + if (peg$silentFails === 0) { peg$fail(peg$e87); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4322,7 +2959,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e118); } + if (peg$silentFails === 0) { peg$fail(peg$e87); } } } s2 = peg$currPos; @@ -4369,7 +3006,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e119); } + if (peg$silentFails === 0) { peg$fail(peg$e88); } } } } @@ -4378,14 +3015,14 @@ function peg$parse(input, options) { } function peg$parsewordBoundary() { - var s0, s1; + let s0, s1; s0 = input.charAt(peg$currPos); if (peg$r12.test(s0)) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e120); } + if (peg$silentFails === 0) { peg$fail(peg$e89); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -4424,30 +3061,36 @@ function peg$parse(input, options) { peg$result = peg$startRuleFunction(); - if (options.peg$library) { - return /** @type {any} */ ({ - peg$result, - peg$currPos, - peg$FAILED, - peg$maxFailExpected, - peg$maxFailPos - }); - } - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { + const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); + function peg$throw() { if (peg$result !== peg$FAILED && peg$currPos < input.length) { peg$fail(peg$endExpectation()); } throw peg$buildStructuredError( peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, peg$maxFailPos < input.length ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) ); } + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos, + peg$success, + peg$throw: peg$success ? undefined : peg$throw, + }); + } + if (peg$success) { + return peg$result; + } else { + peg$throw(); + } } const peg$allowedStartRules = [ diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index cc6f3e9fbfcb..ad60a1a0959d 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -157,7 +157,8 @@ groupQuarter = "group-quarter"i &wordBoundary { return "gr operator "operator" - = (":" / "=") { return "eq"; } + = "*:" { return "contains"; } + / (":" / "=") { return "eq"; } / "!=" { return "neq"; } / ">=" { return "gte"; } / ">" { return "gt"; } @@ -167,7 +168,7 @@ operator "operator" filterOperator = o:operator { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index de8fe4c0aebe..86fef05c3b2f 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -1,4 +1,4 @@ -// @generated by Peggy 4.0.3. +// @generated by Peggy 5.0.6. // // https://peggyjs.org/ @@ -12,624 +12,615 @@ return { operator, left, right }; } -function peg$subclass(child, parent) { - function C() { this.constructor = child; } - C.prototype = parent.prototype; - child.prototype = new C(); -} - -function peg$SyntaxError(message, expected, found, location) { - var self = Error.call(this, message); - // istanbul ignore next Check is a necessary evil to support older environments - if (Object.setPrototypeOf) { - Object.setPrototypeOf(self, peg$SyntaxError.prototype); - } - self.expected = expected; - self.found = found; - self.location = location; - self.name = "SyntaxError"; - return self; -} +class peg$SyntaxError extends SyntaxError { + constructor(message, expected, found, location) { + super(message); + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + } + + format(sources) { + let str = "Error: " + this.message; + if (this.location) { + let src = null; + const st = sources.find(s => s.source === this.location.source); + if (st) { + src = st.text.split(/\r\n|\n|\r/g); + } + const s = this.location.start; + const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + const e = this.location.end; + const filler = "".padEnd(offset_s.line.toString().length, " "); + const line = src[s.line - 1]; + const last = s.line === e.line ? e.column : line.length + 1; + const hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + "".padEnd(s.column - 1, " ") + + "".padEnd(hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; + } -peg$subclass(peg$SyntaxError, Error); + static buildMessage(expected, found) { + function hex(ch) { + return ch.codePointAt(0).toString(16).toUpperCase(); + } -function peg$padEnd(str, targetLength, padString) { - padString = padString || " "; - if (str.length > targetLength) { return str; } - targetLength -= str.length; - padString += padString.repeat(targetLength); - return str + padString.slice(0, targetLength); -} + const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") + ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") + : null; + function unicodeEscape(s) { + if (nonPrintable) { + return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); + } + return s; + } -peg$SyntaxError.prototype.format = function(sources) { - var str = "Error: " + this.message; - if (this.location) { - var src = null; - var k; - for (k = 0; k < sources.length; k++) { - if (sources[k].source === this.location.source) { - src = sources[k].text.split(/\r\n|\n|\r/g); - break; - } - } - var s = this.location.start; - var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) - ? this.location.source.offset(s) - : s; - var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; - if (src) { - var e = this.location.end; - var filler = peg$padEnd("", offset_s.line.toString().length, ' '); - var line = src[s.line - 1]; - var last = s.line === e.line ? e.column : line.length + 1; - var hatLen = (last - s.column) || 1; - str += "\n --> " + loc + "\n" - + filler + " |\n" - + offset_s.line + " | " + line + "\n" - + filler + " | " + peg$padEnd("", s.column - 1, ' ') - + peg$padEnd("", hatLen, "^"); - } else { - str += "\n at " + loc; - } - } - return str; -}; + function literalEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, + function classEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } - class: function(expectation) { - var escapedParts = expectation.parts.map(function(part) { - return Array.isArray(part) - ? classEscape(part[0]) + "-" + classEscape(part[1]) - : classEscape(part); - }); + const DESCRIBE_EXPECTATION_FNS = { + literal(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, - return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; - }, + class(expectation) { + const escapedParts = expectation.parts.map( + part => (Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part)) + ); - any: function() { - return "any character"; - }, + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); + }, - end: function() { - return "end of input"; - }, + any() { + return "any character"; + }, - other: function(expectation) { - return expectation.description; - } - }; + end() { + return "end of input"; + }, - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } + other(expectation) { + return expectation.description; + }, + }; - function literalEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } - function classEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/\]/g, "\\]") - .replace(/\^/g, "\\^") - .replace(/-/g, "\\-") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } + function describeExpected(expected) { + const descriptions = expected.map(describeExpectation); + descriptions.sort(); - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } + if (descriptions.length > 0) { + let j = 1; + for (let i = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } - function describeExpected(expected) { - var descriptions = expected.map(describeExpectation); - var i, j; + switch (descriptions.length) { + case 1: + return descriptions[0]; - descriptions.sort(); + case 2: + return descriptions[0] + " or " + descriptions[1]; - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; } - descriptions.length = j; } - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; } - } - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; +} function peg$parse(input, options) { options = options !== undefined ? options : {}; - var peg$FAILED = {}; - var peg$source = options.grammarSource; - - var peg$startRuleFunctions = { query: peg$parsequery }; - var peg$startRuleFunction = peg$parsequery; - - var peg$c0 = "-"; - var peg$c1 = "report-field"; - var peg$c2 = "reportfield"; - var peg$c3 = ","; - var peg$c4 = "date"; - var peg$c5 = "amount"; - var peg$c6 = "merchant"; - var peg$c7 = "description"; - var peg$c8 = "reportid"; - var peg$c9 = "report-id"; - var peg$c10 = "keyword"; - var peg$c11 = "in"; - var peg$c12 = "currency"; - var peg$c13 = "groupCurrency"; - var peg$c14 = "group-currency"; - var peg$c15 = "tag"; - var peg$c16 = "category"; - var peg$c17 = "to"; - var peg$c18 = "exporter"; - var peg$c19 = "payer"; - var peg$c20 = "taxrate"; - var peg$c21 = "tax-rate"; - var peg$c22 = "cardID"; - var peg$c23 = "card"; - var peg$c24 = "from"; - var peg$c25 = "attendee"; - var peg$c26 = "expenseType"; - var peg$c27 = "expense-type"; - var peg$c28 = "withdrawalType"; - var peg$c29 = "withdrawal-type"; - var peg$c30 = "withdrawalid"; - var peg$c31 = "withdrawal-id"; - var peg$c32 = "billable"; - var peg$c33 = "reimbursable"; - var peg$c34 = "type"; - var peg$c35 = "status"; - var peg$c36 = "sortBy"; - var peg$c37 = "sort-by"; - var peg$c38 = "sortOrder"; - var peg$c39 = "sort-order"; - var peg$c40 = "policyID"; - var peg$c41 = "workspace"; - var peg$c42 = "submitted"; - var peg$c43 = "approved"; - var peg$c44 = "paid"; - var peg$c45 = "exported"; - var peg$c46 = "posted"; - var peg$c47 = "withdrawn"; - var peg$c48 = "groupby"; - var peg$c49 = "group-by"; - var peg$c50 = "limit"; - var peg$c51 = "feed"; - var peg$c52 = "title"; - var peg$c53 = "assignee"; - var peg$c54 = "createdby"; - var peg$c55 = "created-by"; - var peg$c56 = "action"; - var peg$c57 = "total"; - var peg$c58 = "has"; - var peg$c59 = "is"; - var peg$c60 = "purchaseamount"; - var peg$c61 = "purchase-amount"; - var peg$c62 = "purchasecurrency"; - var peg$c63 = "purchase-currency"; - var peg$c64 = "columns"; - var peg$c65 = "view"; - var peg$c66 = "per-diem"; - var peg$c67 = "drafts"; - var peg$c68 = "draft"; - var peg$c69 = "original-amount"; - var peg$c70 = "tax"; - var peg$c71 = "policy-name"; - var peg$c72 = "bank-account"; - var peg$c73 = "long-report-id"; - var peg$c74 = "exported-to"; - var peg$c75 = "exportedto"; - var peg$c76 = "exchange-rate"; - var peg$c77 = "reimbursable-total"; - var peg$c78 = "non-reimbursable-total"; - var peg$c79 = "group-from"; - var peg$c80 = "group-expenses"; - var peg$c81 = "group-total"; - var peg$c82 = "group-card"; - var peg$c83 = "group-feed"; - var peg$c84 = "group-bank-account"; - var peg$c85 = "group-withdrawn"; - var peg$c86 = "group-withdrawal-id"; - var peg$c87 = "group-category"; - var peg$c88 = "group-tag"; - var peg$c89 = "group-merchant"; - var peg$c90 = "group-month"; - var peg$c91 = "group-week"; - var peg$c92 = "group-year"; - var peg$c93 = "group-quarter"; - var peg$c94 = "!="; - var peg$c95 = ">="; - var peg$c96 = ">"; - var peg$c97 = "<="; - var peg$c98 = "<"; - var peg$c99 = "\u201C"; - var peg$c100 = "\u201D"; - var peg$c101 = "\""; - - var peg$r0 = /^[^ \t\r\n\xA0]/; - var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; - var peg$r2 = /^[:=]/; - var peg$r3 = /^[^ ,\t\n\r\xA0]/; - var peg$r4 = /^[ \t\r\n\xA0]/; - var peg$r5 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; - var peg$r6 = /^["\u201C-\u201D]/; - var peg$r7 = /^[^"\u201D\u201C\r\n]/; - var peg$r8 = /^[ \t\n\r\xA0a-zA-Z0-9]/; - var peg$r9 = /^[a-zA-Z0-9]/; - var peg$r10 = /^[ \t\n\r\xA0]/; - var peg$r11 = /^[ \t\n\r\xA0a-zA-Z]/; - var peg$r12 = /^[,]/; - var peg$r13 = /^[ \t\n\r\xA0,]/; - - var peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], true, false); - var peg$e1 = peg$literalExpectation("-", false); - var peg$e2 = peg$literalExpectation("report-field", true); - var peg$e3 = peg$literalExpectation("reportfield", true); - var peg$e4 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false); - var peg$e5 = peg$anyExpectation(); - var peg$e6 = peg$otherExpectation("key"); - var peg$e7 = peg$otherExpectation("default key"); - var peg$e8 = peg$literalExpectation(",", false); - var peg$e9 = peg$literalExpectation("date", true); - var peg$e10 = peg$literalExpectation("amount", true); - var peg$e11 = peg$literalExpectation("merchant", true); - var peg$e12 = peg$literalExpectation("description", true); - var peg$e13 = peg$literalExpectation("reportid", true); - var peg$e14 = peg$literalExpectation("report-id", true); - var peg$e15 = peg$literalExpectation("keyword", true); - var peg$e16 = peg$literalExpectation("in", true); - var peg$e17 = peg$literalExpectation("currency", true); - var peg$e18 = peg$literalExpectation("groupCurrency", false); - var peg$e19 = peg$literalExpectation("group-currency", true); - var peg$e20 = peg$literalExpectation("tag", true); - var peg$e21 = peg$literalExpectation("category", true); - var peg$e22 = peg$literalExpectation("to", true); - var peg$e23 = peg$literalExpectation("exporter", true); - var peg$e24 = peg$literalExpectation("payer", true); - var peg$e25 = peg$literalExpectation("taxRate", true); - var peg$e26 = peg$literalExpectation("tax-rate", true); - var peg$e27 = peg$literalExpectation("cardID", false); - var peg$e28 = peg$literalExpectation("card", true); - var peg$e29 = peg$literalExpectation("from", true); - var peg$e30 = peg$literalExpectation("attendee", true); - var peg$e31 = peg$literalExpectation("expenseType", false); - var peg$e32 = peg$literalExpectation("expense-type", true); - var peg$e33 = peg$literalExpectation("withdrawalType", false); - var peg$e34 = peg$literalExpectation("withdrawal-type", true); - var peg$e35 = peg$literalExpectation("withdrawalID", true); - var peg$e36 = peg$literalExpectation("withdrawal-id", true); - var peg$e37 = peg$literalExpectation("billable", true); - var peg$e38 = peg$literalExpectation("reimbursable", true); - var peg$e39 = peg$literalExpectation("type", true); - var peg$e40 = peg$literalExpectation("status", true); - var peg$e41 = peg$literalExpectation("sortBy", false); - var peg$e42 = peg$literalExpectation("sort-by", true); - var peg$e43 = peg$literalExpectation("sortOrder", false); - var peg$e44 = peg$literalExpectation("sort-order", true); - var peg$e45 = peg$literalExpectation("policyID", false); - var peg$e46 = peg$literalExpectation("workspace", true); - var peg$e47 = peg$literalExpectation("submitted", true); - var peg$e48 = peg$literalExpectation("approved", true); - var peg$e49 = peg$literalExpectation("paid", true); - var peg$e50 = peg$literalExpectation("exported", true); - var peg$e51 = peg$literalExpectation("posted", true); - var peg$e52 = peg$literalExpectation("withdrawn", true); - var peg$e53 = peg$literalExpectation("groupBy", true); - var peg$e54 = peg$literalExpectation("group-by", true); - var peg$e55 = peg$literalExpectation("limit", true); - var peg$e56 = peg$literalExpectation("feed", true); - var peg$e57 = peg$literalExpectation("title", true); - var peg$e58 = peg$literalExpectation("assignee", true); - var peg$e59 = peg$literalExpectation("createdBy", true); - var peg$e60 = peg$literalExpectation("created-by", true); - var peg$e61 = peg$literalExpectation("action", true); - var peg$e62 = peg$literalExpectation("total", true); - var peg$e63 = peg$literalExpectation("has", true); - var peg$e64 = peg$literalExpectation("is", true); - var peg$e65 = peg$literalExpectation("purchaseAmount", true); - var peg$e66 = peg$literalExpectation("purchase-amount", true); - var peg$e67 = peg$literalExpectation("purchaseCurrency", true); - var peg$e68 = peg$literalExpectation("purchase-currency", true); - var peg$e69 = peg$literalExpectation("columns", true); - var peg$e70 = peg$literalExpectation("view", true); - var peg$e71 = peg$literalExpectation("per-diem", true); - var peg$e72 = peg$literalExpectation("drafts", true); - var peg$e73 = peg$literalExpectation("draft", true); - var peg$e74 = peg$literalExpectation("original-amount", true); - var peg$e75 = peg$literalExpectation("tax", true); - var peg$e76 = peg$literalExpectation("policy-name", true); - var peg$e77 = peg$literalExpectation("bank-account", true); - var peg$e78 = peg$literalExpectation("long-report-id", true); - var peg$e79 = peg$literalExpectation("exported-to", true); - var peg$e80 = peg$classExpectation([":", "="], false, false); - var peg$e81 = peg$literalExpectation("exportedTo", true); - var peg$e82 = peg$literalExpectation("exchange-rate", true); - var peg$e83 = peg$literalExpectation("reimbursable-total", true); - var peg$e84 = peg$literalExpectation("non-reimbursable-total", true); - var peg$e85 = peg$literalExpectation("group-from", true); - var peg$e86 = peg$literalExpectation("group-expenses", true); - var peg$e87 = peg$literalExpectation("group-total", true); - var peg$e88 = peg$literalExpectation("group-card", true); - var peg$e89 = peg$literalExpectation("group-feed", true); - var peg$e90 = peg$literalExpectation("group-bank-account", true); - var peg$e91 = peg$literalExpectation("group-withdrawn", true); - var peg$e92 = peg$literalExpectation("group-withdrawal-id", true); - var peg$e93 = peg$literalExpectation("group-category", true); - var peg$e94 = peg$literalExpectation("group-tag", true); - var peg$e95 = peg$literalExpectation("group-merchant", true); - var peg$e96 = peg$literalExpectation("group-month", true); - var peg$e97 = peg$literalExpectation("group-week", true); - var peg$e98 = peg$literalExpectation("group-year", true); - var peg$e99 = peg$literalExpectation("group-quarter", true); - var peg$e100 = peg$otherExpectation("operator"); - var peg$e101 = peg$literalExpectation("!=", false); - var peg$e102 = peg$literalExpectation(">=", false); - var peg$e103 = peg$literalExpectation(">", false); - var peg$e104 = peg$literalExpectation("<=", false); - var peg$e105 = peg$literalExpectation("<", false); - var peg$e106 = peg$otherExpectation("word"); - var peg$e107 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e108 = peg$otherExpectation("whitespace"); - var peg$e109 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e110 = peg$otherExpectation("quote"); - var peg$e111 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e112 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e113 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e114 = peg$literalExpectation("\u201C", false); - var peg$e115 = peg$literalExpectation("\u201D", false); - var peg$e116 = peg$literalExpectation("\"", false); - var peg$e117 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e118 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e119 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e120 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e121 = peg$classExpectation([","], false, false); - var peg$e122 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - - var peg$f0 = function(filters) { return applyDefaults(filters); }; - var peg$f1 = function(head, tail) { - const allFilters = [head, ...tail.map(([_, filter]) => filter)] - .filter(Boolean) - .filter((filter) => filter.right); - rawFilterList = allFilters.map(createRawFilter).filter(Boolean); - - const filtersWithoutDefaults = allFilters.filter((filter) => !filter.isDefault); - - if (!filtersWithoutDefaults.length) { - return null; - } - - const keywords = filtersWithoutDefaults.filter( - (filter) => - filter.left === "keyword" || filter.right?.left === "keyword" - ); - const nonKeywords = filtersWithoutDefaults.filter( - (filter) => - filter.left !== "keyword" && filter.right?.left !== "keyword" - ); - - const keywordFilter = buildFilter( - "eq", - "keyword", - keywords - .map((filter) => filter.right.replaceAll(/^(['"])(.*)\1$/g, "$2")) - .flat() - ); - if (keywordFilter.right.length > 0) { - nonKeywords.push(keywordFilter); - } - return nonKeywords.reduce((result, filter) => - buildFilter("and", result, filter) - ); - }; - var peg$f2 = function(key, op, value) { - updateDefaultValues(key, value); - return {...buildFilter(op, key, value), isDefault: true}; - }; - var peg$f3 = function(value) { - //handle no-breaking space - let word; - if (Array.isArray(value)) { - word = value.join(""); - } else { - word = value; - } - return buildFilter("eq", "keyword", word); - }; - var peg$f4 = function(neg, field, op, values) { - expectingNestedQuote = false; nameOperator = false; + const peg$FAILED = {}; + const peg$source = options.grammarSource; - const key = field; - let operator = op; + const peg$startRuleFunctions = { + query: peg$parsequery, + }; + let peg$startRuleFunction = peg$parsequery; + + const peg$c0 = "-"; + const peg$c1 = "report-field"; + const peg$c2 = "reportfield"; + const peg$c3 = ","; + const peg$c4 = "date"; + const peg$c5 = "amount"; + const peg$c6 = "merchant"; + const peg$c7 = "description"; + const peg$c8 = "reportid"; + const peg$c9 = "report-id"; + const peg$c10 = "keyword"; + const peg$c11 = "in"; + const peg$c12 = "currency"; + const peg$c13 = "groupCurrency"; + const peg$c14 = "group-currency"; + const peg$c15 = "tag"; + const peg$c16 = "category"; + const peg$c17 = "to"; + const peg$c18 = "exporter"; + const peg$c19 = "payer"; + const peg$c20 = "taxrate"; + const peg$c21 = "tax-rate"; + const peg$c22 = "cardID"; + const peg$c23 = "card"; + const peg$c24 = "from"; + const peg$c25 = "attendee"; + const peg$c26 = "expenseType"; + const peg$c27 = "expense-type"; + const peg$c28 = "withdrawalType"; + const peg$c29 = "withdrawal-type"; + const peg$c30 = "withdrawalid"; + const peg$c31 = "withdrawal-id"; + const peg$c32 = "billable"; + const peg$c33 = "reimbursable"; + const peg$c34 = "type"; + const peg$c35 = "status"; + const peg$c36 = "sortBy"; + const peg$c37 = "sort-by"; + const peg$c38 = "sortOrder"; + const peg$c39 = "sort-order"; + const peg$c40 = "policyID"; + const peg$c41 = "workspace"; + const peg$c42 = "submitted"; + const peg$c43 = "approved"; + const peg$c44 = "paid"; + const peg$c45 = "exported"; + const peg$c46 = "posted"; + const peg$c47 = "withdrawn"; + const peg$c48 = "groupby"; + const peg$c49 = "group-by"; + const peg$c50 = "limit"; + const peg$c51 = "feed"; + const peg$c52 = "title"; + const peg$c53 = "assignee"; + const peg$c54 = "createdby"; + const peg$c55 = "created-by"; + const peg$c56 = "action"; + const peg$c57 = "total"; + const peg$c58 = "has"; + const peg$c59 = "is"; + const peg$c60 = "purchaseamount"; + const peg$c61 = "purchase-amount"; + const peg$c62 = "purchasecurrency"; + const peg$c63 = "purchase-currency"; + const peg$c64 = "columns"; + const peg$c65 = "view"; + const peg$c66 = "per-diem"; + const peg$c67 = "drafts"; + const peg$c68 = "draft"; + const peg$c69 = "original-amount"; + const peg$c70 = "tax"; + const peg$c71 = "policy-name"; + const peg$c72 = "bank-account"; + const peg$c73 = "long-report-id"; + const peg$c74 = "exported-to"; + const peg$c75 = "exportedto"; + const peg$c76 = "exchange-rate"; + const peg$c77 = "reimbursable-total"; + const peg$c78 = "non-reimbursable-total"; + const peg$c79 = "group-from"; + const peg$c80 = "group-expenses"; + const peg$c81 = "group-total"; + const peg$c82 = "group-card"; + const peg$c83 = "group-feed"; + const peg$c84 = "group-bank-account"; + const peg$c85 = "group-withdrawn"; + const peg$c86 = "group-withdrawal-id"; + const peg$c87 = "group-category"; + const peg$c88 = "group-tag"; + const peg$c89 = "group-merchant"; + const peg$c90 = "group-month"; + const peg$c91 = "group-week"; + const peg$c92 = "group-year"; + const peg$c93 = "group-quarter"; + const peg$c94 = "*:"; + const peg$c95 = "!="; + const peg$c96 = ">="; + const peg$c97 = ">"; + const peg$c98 = "<="; + const peg$c99 = "<"; + const peg$c100 = "\u201C"; + const peg$c101 = "\u201D"; + const peg$c102 = "\""; + + const peg$r0 = /^[^ \t\r\n\xA0]/; + const peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; + const peg$r2 = /^[:=]/; + const peg$r3 = /^[^ ,\t\n\r\xA0]/; + const peg$r4 = /^[ \t\r\n\xA0]/; + const peg$r5 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; + const peg$r6 = /^["\u201C-\u201D]/; + const peg$r7 = /^[^"\u201D\u201C\r\n]/; + const peg$r8 = /^[ \t\n\r\xA0a-zA-Z0-9]/; + const peg$r9 = /^[a-zA-Z0-9]/; + const peg$r10 = /^[ \t\n\r\xA0]/; + const peg$r11 = /^[ \t\n\r\xA0a-zA-Z]/; + const peg$r12 = /^[,]/; + const peg$r13 = /^[ \t\n\r\xA0,]/; + + const peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], true, false, false); + const peg$e1 = peg$literalExpectation("-", false); + const peg$e2 = peg$literalExpectation("report-field", true); + const peg$e3 = peg$literalExpectation("reportfield", true); + const peg$e4 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false, false); + const peg$e5 = peg$anyExpectation(); + const peg$e6 = peg$otherExpectation("key"); + const peg$e7 = peg$otherExpectation("default key"); + const peg$e8 = peg$literalExpectation(",", false); + const peg$e9 = peg$literalExpectation("date", true); + const peg$e10 = peg$literalExpectation("amount", true); + const peg$e11 = peg$literalExpectation("merchant", true); + const peg$e12 = peg$literalExpectation("description", true); + const peg$e13 = peg$literalExpectation("reportid", true); + const peg$e14 = peg$literalExpectation("report-id", true); + const peg$e15 = peg$literalExpectation("keyword", true); + const peg$e16 = peg$literalExpectation("in", true); + const peg$e17 = peg$literalExpectation("currency", true); + const peg$e18 = peg$literalExpectation("groupCurrency", false); + const peg$e19 = peg$literalExpectation("group-currency", true); + const peg$e20 = peg$literalExpectation("tag", true); + const peg$e21 = peg$literalExpectation("category", true); + const peg$e22 = peg$literalExpectation("to", true); + const peg$e23 = peg$literalExpectation("exporter", true); + const peg$e24 = peg$literalExpectation("payer", true); + const peg$e25 = peg$literalExpectation("taxRate", true); + const peg$e26 = peg$literalExpectation("tax-rate", true); + const peg$e27 = peg$literalExpectation("cardID", false); + const peg$e28 = peg$literalExpectation("card", true); + const peg$e29 = peg$literalExpectation("from", true); + const peg$e30 = peg$literalExpectation("attendee", true); + const peg$e31 = peg$literalExpectation("expenseType", false); + const peg$e32 = peg$literalExpectation("expense-type", true); + const peg$e33 = peg$literalExpectation("withdrawalType", false); + const peg$e34 = peg$literalExpectation("withdrawal-type", true); + const peg$e35 = peg$literalExpectation("withdrawalID", true); + const peg$e36 = peg$literalExpectation("withdrawal-id", true); + const peg$e37 = peg$literalExpectation("billable", true); + const peg$e38 = peg$literalExpectation("reimbursable", true); + const peg$e39 = peg$literalExpectation("type", true); + const peg$e40 = peg$literalExpectation("status", true); + const peg$e41 = peg$literalExpectation("sortBy", false); + const peg$e42 = peg$literalExpectation("sort-by", true); + const peg$e43 = peg$literalExpectation("sortOrder", false); + const peg$e44 = peg$literalExpectation("sort-order", true); + const peg$e45 = peg$literalExpectation("policyID", false); + const peg$e46 = peg$literalExpectation("workspace", true); + const peg$e47 = peg$literalExpectation("submitted", true); + const peg$e48 = peg$literalExpectation("approved", true); + const peg$e49 = peg$literalExpectation("paid", true); + const peg$e50 = peg$literalExpectation("exported", true); + const peg$e51 = peg$literalExpectation("posted", true); + const peg$e52 = peg$literalExpectation("withdrawn", true); + const peg$e53 = peg$literalExpectation("groupBy", true); + const peg$e54 = peg$literalExpectation("group-by", true); + const peg$e55 = peg$literalExpectation("limit", true); + const peg$e56 = peg$literalExpectation("feed", true); + const peg$e57 = peg$literalExpectation("title", true); + const peg$e58 = peg$literalExpectation("assignee", true); + const peg$e59 = peg$literalExpectation("createdBy", true); + const peg$e60 = peg$literalExpectation("created-by", true); + const peg$e61 = peg$literalExpectation("action", true); + const peg$e62 = peg$literalExpectation("total", true); + const peg$e63 = peg$literalExpectation("has", true); + const peg$e64 = peg$literalExpectation("is", true); + const peg$e65 = peg$literalExpectation("purchaseAmount", true); + const peg$e66 = peg$literalExpectation("purchase-amount", true); + const peg$e67 = peg$literalExpectation("purchaseCurrency", true); + const peg$e68 = peg$literalExpectation("purchase-currency", true); + const peg$e69 = peg$literalExpectation("columns", true); + const peg$e70 = peg$literalExpectation("view", true); + const peg$e71 = peg$literalExpectation("per-diem", true); + const peg$e72 = peg$literalExpectation("drafts", true); + const peg$e73 = peg$literalExpectation("draft", true); + const peg$e74 = peg$literalExpectation("original-amount", true); + const peg$e75 = peg$literalExpectation("tax", true); + const peg$e76 = peg$literalExpectation("policy-name", true); + const peg$e77 = peg$literalExpectation("bank-account", true); + const peg$e78 = peg$literalExpectation("long-report-id", true); + const peg$e79 = peg$literalExpectation("exported-to", true); + const peg$e80 = peg$classExpectation([":", "="], false, false, false); + const peg$e81 = peg$literalExpectation("exportedTo", true); + const peg$e82 = peg$literalExpectation("exchange-rate", true); + const peg$e83 = peg$literalExpectation("reimbursable-total", true); + const peg$e84 = peg$literalExpectation("non-reimbursable-total", true); + const peg$e85 = peg$literalExpectation("group-from", true); + const peg$e86 = peg$literalExpectation("group-expenses", true); + const peg$e87 = peg$literalExpectation("group-total", true); + const peg$e88 = peg$literalExpectation("group-card", true); + const peg$e89 = peg$literalExpectation("group-feed", true); + const peg$e90 = peg$literalExpectation("group-bank-account", true); + const peg$e91 = peg$literalExpectation("group-withdrawn", true); + const peg$e92 = peg$literalExpectation("group-withdrawal-id", true); + const peg$e93 = peg$literalExpectation("group-category", true); + const peg$e94 = peg$literalExpectation("group-tag", true); + const peg$e95 = peg$literalExpectation("group-merchant", true); + const peg$e96 = peg$literalExpectation("group-month", true); + const peg$e97 = peg$literalExpectation("group-week", true); + const peg$e98 = peg$literalExpectation("group-year", true); + const peg$e99 = peg$literalExpectation("group-quarter", true); + const peg$e100 = peg$otherExpectation("operator"); + const peg$e101 = peg$literalExpectation("*:", false); + const peg$e102 = peg$literalExpectation("!=", false); + const peg$e103 = peg$literalExpectation(">=", false); + const peg$e104 = peg$literalExpectation(">", false); + const peg$e105 = peg$literalExpectation("<=", false); + const peg$e106 = peg$literalExpectation("<", false); + const peg$e107 = peg$otherExpectation("word"); + const peg$e108 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false, false); + const peg$e109 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false, false); + const peg$e110 = peg$otherExpectation("quote"); + const peg$e111 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false, false); + const peg$e112 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false, false); + const peg$e113 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false, false); + const peg$e114 = peg$literalExpectation("\u201C", false); + const peg$e115 = peg$literalExpectation("\u201D", false); + const peg$e116 = peg$literalExpectation("\"", false); + const peg$e117 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); + const peg$e118 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); + const peg$e119 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false, false); + const peg$e120 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false, false); + const peg$e121 = peg$classExpectation([","], false, false, false); + const peg$e122 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false, false); + + function peg$f0(filters) { return applyDefaults(filters); } + function peg$f1(head, tail) { + const allFilters = [head, ...tail.map(([_, filter]) => filter)] + .filter(Boolean) + .filter((filter) => filter.right); + rawFilterList = allFilters.map(createRawFilter).filter(Boolean); + + const filtersWithoutDefaults = allFilters.filter((filter) => !filter.isDefault); + + if (!filtersWithoutDefaults.length) { + return null; + } - // Apply negation only when "-" is present and the key can be negated - if (neg && !nonNegatableKeys.has(key)) { - if (operator === "eq") { - operator = "neq"; - } + const keywords = filtersWithoutDefaults.filter( + (filter) => + filter.left === "keyword" || filter.right?.left === "keyword" + ); + const nonKeywords = filtersWithoutDefaults.filter( + (filter) => + filter.left !== "keyword" && filter.right?.left !== "keyword" + ); + + const keywordFilter = buildFilter( + "eq", + "keyword", + keywords + .map((filter) => filter.right.replaceAll(/^(['"])(.*)\1$/g, "$2")) + .flat() + ); + if (keywordFilter.right.length > 0) { + nonKeywords.push(keywordFilter); + } + return nonKeywords.reduce((result, filter) => + buildFilter("and", result, filter) + ); + } + function peg$f2(key, op, value) { + updateDefaultValues(key, value); + return {...buildFilter(op, key, value), isDefault: true}; + } + function peg$f3(value) { + //handle no-breaking space + let word; + if (Array.isArray(value)) { + word = value.join(""); + } else { + word = value; + } + return buildFilter("eq", "keyword", word); + } + function peg$f4(neg, field, op, values) { + expectingNestedQuote = false; nameOperator = false; + + const key = field; + let operator = op; + + // Apply negation only when "-" is present and the key can be negated + if (neg && !nonNegatableKeys.has(key)) { + if (operator === "eq") { + operator = "neq"; } + } - return buildFilter(operator, key, values); - }; - var peg$f5 = function(rest) { - const suffix = rest.replaceAll(/^-+/g, ""); - return "reportField-" + (suffix ? suffix : ""); - }; - var peg$f6 = function(k) { - nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); - return k; - }; - var peg$f7 = function(parts, empty) { - const value = parts.flat().filter(Boolean).map((word) => { - if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { - return word.slice(1, -1); - } - return word; - }); - if (value.length > 1) { - return value.filter((word) => word.length > 0); + return buildFilter(operator, key, values); + } + function peg$f5(rest) { + const suffix = rest.replaceAll(/^-+/g, ""); + return "reportField-" + (suffix ? suffix : ""); + } + function peg$f6(k) { + nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); + return k; + } + function peg$f7(parts, empty) { + const value = parts.flat().filter(Boolean).map((word) => { + if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { + return word.slice(1, -1); } - return value[0]; - }; - var peg$f8 = function() { return "date"; }; - var peg$f9 = function() { return "amount"; }; - var peg$f10 = function() { return "merchant"; }; - var peg$f11 = function() { return "description"; }; - var peg$f12 = function() { return "reportID"; }; - var peg$f13 = function() { return "keyword"; }; - var peg$f14 = function() { return "in"; }; - var peg$f15 = function() { return "currency"; }; - var peg$f16 = function() { return "groupCurrency"; }; - var peg$f17 = function() { return "tag"; }; - var peg$f18 = function() { return "category"; }; - var peg$f19 = function() { return "to"; }; - var peg$f20 = function() { return "exporter"; }; - var peg$f21 = function() { return "payer"; }; - var peg$f22 = function() { return "taxRate"; }; - var peg$f23 = function() { return "cardID"; }; - var peg$f24 = function() { return "from"; }; - var peg$f25 = function() {return "attendee"}; - var peg$f26 = function() { return "expenseType"; }; - var peg$f27 = function() { return "withdrawalType"; }; - var peg$f28 = function() { return "withdrawalID"; }; - var peg$f29 = function() { return "billable"; }; - var peg$f30 = function() { return "reimbursable"; }; - var peg$f31 = function() { return "type"; }; - var peg$f32 = function() { return "status"; }; - var peg$f33 = function() { return "sortBy"; }; - var peg$f34 = function() { return "sortOrder"; }; - var peg$f35 = function() { return "policyID"; }; - var peg$f36 = function() { return "submitted"; }; - var peg$f37 = function() { return "approved"; }; - var peg$f38 = function() { return "paid"; }; - var peg$f39 = function() { return "exported"; }; - var peg$f40 = function() { return "posted"; }; - var peg$f41 = function() { return "withdrawn"; }; - var peg$f42 = function() { return "groupBy"; }; - var peg$f43 = function() { return "limit"; }; - var peg$f44 = function() { return "feed"; }; - var peg$f45 = function() { return "title"; }; - var peg$f46 = function() { return "assignee"; }; - var peg$f47 = function() { return "createdBy"; }; - var peg$f48 = function() { return "action"; }; - var peg$f49 = function() {return "total"; }; - var peg$f50 = function() {return "has"; }; - var peg$f51 = function() {return "is"; }; - var peg$f52 = function() {return "purchaseAmount"}; - var peg$f53 = function() {return "purchaseCurrency"}; - var peg$f54 = function() { - isColumnsContext = true; - return "columns"; -}; - var peg$f55 = function() {return "view"}; - var peg$f56 = function() { return isColumnsContext; }; - var peg$f57 = function() { return "perDiem"; }; - var peg$f58 = function() { return "drafts"; }; - var peg$f59 = function() { return "originalamount"; }; - var peg$f60 = function() { return "taxAmount"; }; - var peg$f61 = function() { return "taxrate"; }; - var peg$f62 = function() { return "policyname"; }; - var peg$f63 = function() { return "withdrawalID"; }; - var peg$f64 = function() { return "bankAccount"; }; - var peg$f65 = function() { return "reportID"; }; - var peg$f66 = function() { return "base62ReportID"; }; - var peg$f67 = function() { return "exportedto"; }; - var peg$f68 = function() { return "exportedTo"; }; - var peg$f69 = function() { return "exportedTo"; }; - var peg$f70 = function() { return "exchangeRate"; }; - var peg$f71 = function() { return "reimbursableTotal"; }; - var peg$f72 = function() { return "nonReimbursableTotal"; }; - var peg$f73 = function() { return "groupFrom"; }; - var peg$f74 = function() { return "groupExpenses"; }; - var peg$f75 = function() { return "groupTotal"; }; - var peg$f76 = function() { return "groupCard"; }; - var peg$f77 = function() { return "groupFeed"; }; - var peg$f78 = function() { return "groupBankAccount"; }; - var peg$f79 = function() { return "groupWithdrawn"; }; - var peg$f80 = function() { return "groupWithdrawalID"; }; - var peg$f81 = function() { return "groupCategory"; }; - var peg$f82 = function() { return "groupTag"; }; - var peg$f83 = function() { return "groupMerchant"; }; - var peg$f84 = function() { return "groupmonth"; }; - var peg$f85 = function() { return "groupweek"; }; - var peg$f86 = function() { return "groupyear"; }; - var peg$f87 = function() { return "groupquarter"; }; - var peg$f88 = function() { return "eq"; }; - var peg$f89 = function() { return "neq"; }; - var peg$f90 = function() { return "gte"; }; - var peg$f91 = function() { return "gt"; }; - var peg$f92 = function() { return "lte"; }; - var peg$f93 = function() { return "lt"; }; - var peg$f94 = function(o) { - if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found - } - isColumnsContext = false; - return o; - }; - var peg$f95 = function(chars) { return chars.join("").trim(); }; - var peg$f96 = function() { - isColumnsContext = false; - return "and"; -}; - var peg$f97 = function() { return expectingNestedQuote; }; - var peg$f98 = function(start, inner, end) { //handle no-breaking space - return [...start, '"', ...inner, '"', ...end].join(""); - }; - var peg$f99 = function(start) {return "“"}; - var peg$f100 = function(start) {return "”"}; - var peg$f101 = function(start) {return "\""}; - var peg$f102 = function(start, inner, end) { + return word; + }); + if (value.length > 1) { + return value.filter((word) => word.length > 0); + } + return value[0]; + } + function peg$f8() { return "date"; } + function peg$f9() { return "amount"; } + function peg$f10() { return "merchant"; } + function peg$f11() { return "description"; } + function peg$f12() { return "reportID"; } + function peg$f13() { return "keyword"; } + function peg$f14() { return "in"; } + function peg$f15() { return "currency"; } + function peg$f16() { return "groupCurrency"; } + function peg$f17() { return "tag"; } + function peg$f18() { return "category"; } + function peg$f19() { return "to"; } + function peg$f20() { return "exporter"; } + function peg$f21() { return "payer"; } + function peg$f22() { return "taxRate"; } + function peg$f23() { return "cardID"; } + function peg$f24() { return "from"; } + function peg$f25() {return "attendee" } + function peg$f26() { return "expenseType"; } + function peg$f27() { return "withdrawalType"; } + function peg$f28() { return "withdrawalID"; } + function peg$f29() { return "billable"; } + function peg$f30() { return "reimbursable"; } + function peg$f31() { return "type"; } + function peg$f32() { return "status"; } + function peg$f33() { return "sortBy"; } + function peg$f34() { return "sortOrder"; } + function peg$f35() { return "policyID"; } + function peg$f36() { return "submitted"; } + function peg$f37() { return "approved"; } + function peg$f38() { return "paid"; } + function peg$f39() { return "exported"; } + function peg$f40() { return "posted"; } + function peg$f41() { return "withdrawn"; } + function peg$f42() { return "groupBy"; } + function peg$f43() { return "limit"; } + function peg$f44() { return "feed"; } + function peg$f45() { return "title"; } + function peg$f46() { return "assignee"; } + function peg$f47() { return "createdBy"; } + function peg$f48() { return "action"; } + function peg$f49() {return "total"; } + function peg$f50() {return "has"; } + function peg$f51() {return "is"; } + function peg$f52() {return "purchaseAmount" } + function peg$f53() {return "purchaseCurrency" } + function peg$f54() { + isColumnsContext = true; + return "columns"; + } + function peg$f55() {return "view" } + function peg$f56() { return isColumnsContext; } + function peg$f57() { return "perDiem"; } + function peg$f58() { return "drafts"; } + function peg$f59() { return "originalamount"; } + function peg$f60() { return "taxAmount"; } + function peg$f61() { return "taxrate"; } + function peg$f62() { return "policyname"; } + function peg$f63() { return "withdrawalID"; } + function peg$f64() { return "bankAccount"; } + function peg$f65() { return "reportID"; } + function peg$f66() { return "base62ReportID"; } + function peg$f67() { return "exportedto"; } + function peg$f68() { return "exportedTo"; } + function peg$f69() { return "exportedTo"; } + function peg$f70() { return "exchangeRate"; } + function peg$f71() { return "reimbursableTotal"; } + function peg$f72() { return "nonReimbursableTotal"; } + function peg$f73() { return "groupFrom"; } + function peg$f74() { return "groupExpenses"; } + function peg$f75() { return "groupTotal"; } + function peg$f76() { return "groupCard"; } + function peg$f77() { return "groupFeed"; } + function peg$f78() { return "groupBankAccount"; } + function peg$f79() { return "groupWithdrawn"; } + function peg$f80() { return "groupWithdrawalID"; } + function peg$f81() { return "groupCategory"; } + function peg$f82() { return "groupTag"; } + function peg$f83() { return "groupMerchant"; } + function peg$f84() { return "groupmonth"; } + function peg$f85() { return "groupweek"; } + function peg$f86() { return "groupyear"; } + function peg$f87() { return "groupquarter"; } + function peg$f88() { return "contains"; } + function peg$f89() { return "eq"; } + function peg$f90() { return "neq"; } + function peg$f91() { return "gte"; } + function peg$f92() { return "gt"; } + function peg$f93() { return "lte"; } + function peg$f94() { return "lt"; } + function peg$f95(o) { + if (nameOperator) { + expectingNestedQuote = (o === "eq" || o === "contains"); + } + isColumnsContext = false; + return o; + } + function peg$f96(chars) { return chars.join("").trim(); } + function peg$f97() { + isColumnsContext = false; + return "and"; + } + function peg$f98() { return expectingNestedQuote; } + function peg$f99(start, inner, end) { //handle no-breaking space + return [...start, '"', ...inner, '"', ...end].join(""); + } + function peg$f100(start) {return "“" } + function peg$f101(start) {return "”" } + function peg$f102(start) {return "\"" } + function peg$f103(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); -}; - var peg$currPos = options.peg$currPos | 0; - var peg$savedPos = peg$currPos; - var peg$posDetailsCache = [{ line: 1, column: 1 }]; - var peg$maxFailPos = peg$currPos; - var peg$maxFailExpected = options.peg$maxFailExpected || []; - var peg$silentFails = options.peg$silentFails | 0; + } + let peg$currPos = options.peg$currPos | 0; + let peg$savedPos = peg$currPos; + const peg$posDetailsCache = [{ line: 1, column: 1 }]; + let peg$maxFailPos = peg$currPos; + let peg$maxFailExpected = options.peg$maxFailExpected || []; + let peg$silentFails = options.peg$silentFails | 0; - var peg$result; + let peg$result; if (options.startRule) { if (!(options.startRule in peg$startRuleFunctions)) { @@ -651,7 +642,7 @@ function peg$parse(input, options) { return { source: peg$source, start: peg$savedPos, - end: peg$currPos + end: peg$currPos, }; } @@ -679,12 +670,20 @@ function peg$parse(input, options) { throw peg$buildSimpleError(message, location); } + function peg$getUnicode(pos = peg$currPos) { + const cp = input.codePointAt(pos); + if (cp === undefined) { + return ""; + } + return String.fromCodePoint(cp); + } + function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; + return { type: "literal", text, ignoreCase }; } - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + function peg$classExpectation(parts, inverted, ignoreCase, unicode) { + return { type: "class", parts, inverted, ignoreCase, unicode }; } function peg$anyExpectation() { @@ -696,12 +695,12 @@ function peg$parse(input, options) { } function peg$otherExpectation(description) { - return { type: "other", description: description }; + return { type: "other", description }; } function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos]; - var p; + let details = peg$posDetailsCache[pos]; + let p; if (details) { return details; @@ -716,7 +715,7 @@ function peg$parse(input, options) { details = peg$posDetailsCache[p]; details = { line: details.line, - column: details.column + column: details.column, }; while (p < pos) { @@ -737,21 +736,21 @@ function peg$parse(input, options) { } function peg$computeLocation(startPos, endPos, offset) { - var startPosDetails = peg$computePosDetails(startPos); - var endPosDetails = peg$computePosDetails(endPos); + const startPosDetails = peg$computePosDetails(startPos); + const endPosDetails = peg$computePosDetails(endPos); - var res = { + const res = { source: peg$source, start: { offset: startPos, line: startPosDetails.line, - column: startPosDetails.column + column: startPosDetails.column, }, end: { offset: endPos, line: endPosDetails.line, - column: endPosDetails.column - } + column: endPosDetails.column, + }, }; if (offset && peg$source && (typeof peg$source.offset === "function")) { res.start = peg$source.offset(res.start); @@ -785,7 +784,7 @@ function peg$parse(input, options) { } function peg$parsequery() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -801,7 +800,7 @@ function peg$parse(input, options) { } function peg$parsefilterList() { - var s0, s1, s2, s3, s4, s5; + let s0, s1, s2, s3, s4, s5; s0 = peg$currPos; s1 = peg$parsefilter(); @@ -841,7 +840,7 @@ function peg$parse(input, options) { } function peg$parsefilter() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parsestandardFilter(); @@ -862,7 +861,7 @@ function peg$parse(input, options) { } function peg$parsedefaultFilter() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; s0 = peg$currPos; s1 = peg$parse_(); @@ -893,7 +892,7 @@ function peg$parse(input, options) { } function peg$parsefreeTextFilter() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -935,7 +934,7 @@ function peg$parse(input, options) { } function peg$parsestandardFilter() { - var s0, s1, s2, s3, s4, s5, s6, s7; + let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = peg$parse_(); @@ -976,12 +975,12 @@ function peg$parse(input, options) { } function peg$parsereportFieldDynamic() { - var s0, s1, s2, s3, s4, s5, s6, s7; + let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c1) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e2); } @@ -989,7 +988,7 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c2) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e3); } @@ -1097,7 +1096,7 @@ function peg$parse(input, options) { } function peg$parsekey() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -1238,7 +1237,7 @@ function peg$parse(input, options) { } function peg$parsefilterKey() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parsekey(); @@ -1252,7 +1251,7 @@ function peg$parse(input, options) { } function peg$parsedefaultKey() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -1297,7 +1296,7 @@ function peg$parse(input, options) { } function peg$parseidentifier() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; s0 = peg$currPos; s1 = []; @@ -1420,12 +1419,12 @@ function peg$parse(input, options) { } function peg$parsedate() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c4) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e9); } @@ -1440,12 +1439,12 @@ function peg$parse(input, options) { } function peg$parseamount() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c5) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e10); } @@ -1460,12 +1459,12 @@ function peg$parse(input, options) { } function peg$parsemerchant() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c6) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e11); } @@ -1480,12 +1479,12 @@ function peg$parse(input, options) { } function peg$parsedescription() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c7) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e12); } @@ -1500,11 +1499,11 @@ function peg$parse(input, options) { } function peg$parsereportID() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 8); if (s0.toLowerCase() === peg$c8) { - peg$currPos += 8; + peg$currPos += (8); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e13); } @@ -1513,7 +1512,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c9) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e14); } @@ -1529,12 +1528,12 @@ function peg$parse(input, options) { } function peg$parsekeyword() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c10) { - peg$currPos += 7; + peg$currPos += (7); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e15); } @@ -1549,12 +1548,12 @@ function peg$parse(input, options) { } function peg$parsein() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c11) { - peg$currPos += 2; + peg$currPos += (2); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e16); } @@ -1569,12 +1568,12 @@ function peg$parse(input, options) { } function peg$parsecurrency() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c12) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e17); } @@ -1589,7 +1588,7 @@ function peg$parse(input, options) { } function peg$parsegroupCurrency() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 13) === peg$c13) { s0 = peg$c13; @@ -1602,7 +1601,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c14) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e19); } @@ -1618,12 +1617,12 @@ function peg$parse(input, options) { } function peg$parsetag() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); if (s1.toLowerCase() === peg$c15) { - peg$currPos += 3; + peg$currPos += (3); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e20); } @@ -1638,12 +1637,12 @@ function peg$parse(input, options) { } function peg$parsecategory() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c16) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e21); } @@ -1658,12 +1657,12 @@ function peg$parse(input, options) { } function peg$parseto() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c17) { - peg$currPos += 2; + peg$currPos += (2); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e22); } @@ -1678,12 +1677,12 @@ function peg$parse(input, options) { } function peg$parseexporter() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c18) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e23); } @@ -1698,12 +1697,12 @@ function peg$parse(input, options) { } function peg$parsepayer() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c19) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e24); } @@ -1718,11 +1717,11 @@ function peg$parse(input, options) { } function peg$parsetaxRate() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 7); if (s0.toLowerCase() === peg$c20) { - peg$currPos += 7; + peg$currPos += (7); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e25); } @@ -1731,7 +1730,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c21) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e26); } @@ -1747,7 +1746,7 @@ function peg$parse(input, options) { } function peg$parsecardID() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 6) === peg$c22) { s0 = peg$c22; @@ -1760,7 +1759,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c23) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e28); } @@ -1776,12 +1775,12 @@ function peg$parse(input, options) { } function peg$parsefrom() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c24) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e29); } @@ -1796,12 +1795,12 @@ function peg$parse(input, options) { } function peg$parseattendee() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c25) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e30); } @@ -1816,7 +1815,7 @@ function peg$parse(input, options) { } function peg$parseexpenseType() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 11) === peg$c26) { s0 = peg$c26; @@ -1829,7 +1828,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c27) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e32); } @@ -1845,7 +1844,7 @@ function peg$parse(input, options) { } function peg$parsewithdrawalType() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 14) === peg$c28) { s0 = peg$c28; @@ -1858,7 +1857,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c29) { - peg$currPos += 15; + peg$currPos += (15); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e34); } @@ -1874,11 +1873,11 @@ function peg$parse(input, options) { } function peg$parsewithdrawalID() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 12); if (s0.toLowerCase() === peg$c30) { - peg$currPos += 12; + peg$currPos += (12); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e35); } @@ -1887,7 +1886,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c31) { - peg$currPos += 13; + peg$currPos += (13); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e36); } @@ -1903,12 +1902,12 @@ function peg$parse(input, options) { } function peg$parsebillable() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c32) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e37); } @@ -1923,12 +1922,12 @@ function peg$parse(input, options) { } function peg$parsereimbursable() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c33) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e38); } @@ -1943,12 +1942,12 @@ function peg$parse(input, options) { } function peg$parsetype() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c34) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e39); } @@ -1963,12 +1962,12 @@ function peg$parse(input, options) { } function peg$parsestatus() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c35) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e40); } @@ -1983,7 +1982,7 @@ function peg$parse(input, options) { } function peg$parsesortBy() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 6) === peg$c36) { s0 = peg$c36; @@ -1996,7 +1995,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c37) { - peg$currPos += 7; + peg$currPos += (7); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e42); } @@ -2012,7 +2011,7 @@ function peg$parse(input, options) { } function peg$parsesortOrder() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 9) === peg$c38) { s0 = peg$c38; @@ -2025,7 +2024,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c39) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e44); } @@ -2041,7 +2040,7 @@ function peg$parse(input, options) { } function peg$parsepolicyID() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 8) === peg$c40) { s0 = peg$c40; @@ -2054,7 +2053,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c41) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e46); } @@ -2070,12 +2069,12 @@ function peg$parse(input, options) { } function peg$parsesubmitted() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c42) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e47); } @@ -2090,12 +2089,12 @@ function peg$parse(input, options) { } function peg$parseapproved() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c43) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e48); } @@ -2110,12 +2109,12 @@ function peg$parse(input, options) { } function peg$parsepaid() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c44) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e49); } @@ -2130,12 +2129,12 @@ function peg$parse(input, options) { } function peg$parseexported() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c45) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e50); } @@ -2150,12 +2149,12 @@ function peg$parse(input, options) { } function peg$parseposted() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c46) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e51); } @@ -2170,12 +2169,12 @@ function peg$parse(input, options) { } function peg$parsewithdrawn() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c47) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e52); } @@ -2190,11 +2189,11 @@ function peg$parse(input, options) { } function peg$parsegroupBy() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 7); if (s0.toLowerCase() === peg$c48) { - peg$currPos += 7; + peg$currPos += (7); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e53); } @@ -2203,7 +2202,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c49) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e54); } @@ -2219,12 +2218,12 @@ function peg$parse(input, options) { } function peg$parselimit() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c50) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e55); } @@ -2239,12 +2238,12 @@ function peg$parse(input, options) { } function peg$parsefeed() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c51) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e56); } @@ -2259,12 +2258,12 @@ function peg$parse(input, options) { } function peg$parsetitle() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c52) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e57); } @@ -2279,12 +2278,12 @@ function peg$parse(input, options) { } function peg$parseassignee() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c53) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e58); } @@ -2299,11 +2298,11 @@ function peg$parse(input, options) { } function peg$parsecreatedBy() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 9); if (s0.toLowerCase() === peg$c54) { - peg$currPos += 9; + peg$currPos += (9); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e59); } @@ -2312,7 +2311,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c55) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e60); } @@ -2328,12 +2327,12 @@ function peg$parse(input, options) { } function peg$parseaction() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c56) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e61); } @@ -2348,12 +2347,12 @@ function peg$parse(input, options) { } function peg$parsetotal() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c57) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e62); } @@ -2368,12 +2367,12 @@ function peg$parse(input, options) { } function peg$parsehas() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); if (s1.toLowerCase() === peg$c58) { - peg$currPos += 3; + peg$currPos += (3); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e63); } @@ -2388,12 +2387,12 @@ function peg$parse(input, options) { } function peg$parseis() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c59) { - peg$currPos += 2; + peg$currPos += (2); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e64); } @@ -2408,11 +2407,11 @@ function peg$parse(input, options) { } function peg$parsepurchaseAmount() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 14); if (s0.toLowerCase() === peg$c60) { - peg$currPos += 14; + peg$currPos += (14); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e65); } @@ -2421,7 +2420,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c61) { - peg$currPos += 15; + peg$currPos += (15); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e66); } @@ -2437,11 +2436,11 @@ function peg$parse(input, options) { } function peg$parsepurchaseCurrency() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 16); if (s0.toLowerCase() === peg$c62) { - peg$currPos += 16; + peg$currPos += (16); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e67); } @@ -2450,7 +2449,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); if (s1.toLowerCase() === peg$c63) { - peg$currPos += 17; + peg$currPos += (17); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e68); } @@ -2466,12 +2465,12 @@ function peg$parse(input, options) { } function peg$parsecolumns() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c64) { - peg$currPos += 7; + peg$currPos += (7); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e69); } @@ -2486,12 +2485,12 @@ function peg$parse(input, options) { } function peg$parseview() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c65) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e70); } @@ -2506,7 +2505,7 @@ function peg$parse(input, options) { } function peg$parsevalues() { - var s0, s1, s2; + let s0, s1, s2; s0 = peg$currPos; peg$savedPos = peg$currPos; @@ -2540,7 +2539,7 @@ function peg$parse(input, options) { } function peg$parsecolumnsValues() { - var s0; + let s0; s0 = peg$parseoriginalAmount(); if (s0 === peg$FAILED) { @@ -2626,12 +2625,12 @@ function peg$parse(input, options) { } function peg$parseperDiem() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c66) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e71); } @@ -2663,12 +2662,12 @@ function peg$parse(input, options) { } function peg$parsedraft() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c67) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e72); } @@ -2676,7 +2675,7 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c68) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e73); } @@ -2709,12 +2708,12 @@ function peg$parse(input, options) { } function peg$parseoriginalAmount() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c69) { - peg$currPos += 15; + peg$currPos += (15); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e74); } @@ -2746,12 +2745,12 @@ function peg$parse(input, options) { } function peg$parsetaxAmount() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); if (s1.toLowerCase() === peg$c70) { - peg$currPos += 3; + peg$currPos += (3); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e75); } @@ -2783,12 +2782,12 @@ function peg$parse(input, options) { } function peg$parsetaxRateValue() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c21) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e26); } @@ -2820,12 +2819,12 @@ function peg$parse(input, options) { } function peg$parsepolicyName() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c71) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e76); } @@ -2857,12 +2856,12 @@ function peg$parse(input, options) { } function peg$parsewithdrawalId() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c31) { - peg$currPos += 13; + peg$currPos += (13); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e36); } @@ -2894,12 +2893,12 @@ function peg$parse(input, options) { } function peg$parsebankAccount() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c72) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e77); } @@ -2931,12 +2930,12 @@ function peg$parse(input, options) { } function peg$parsereportId() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c73) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e78); } @@ -2968,12 +2967,12 @@ function peg$parse(input, options) { } function peg$parsebase62ReportId() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c9) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e14); } @@ -3005,12 +3004,12 @@ function peg$parse(input, options) { } function peg$parseexportedToColumnValue() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c74) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e79); } @@ -3042,7 +3041,7 @@ function peg$parse(input, options) { } function peg$parseexportedToKeyEnd() { - var s0, s1, s2; + let s0, s1, s2; s0 = peg$parsewordBoundary(); if (s0 === peg$FAILED) { @@ -3068,12 +3067,12 @@ function peg$parse(input, options) { } function peg$parseexportedTo() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c75) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e81); } @@ -3104,7 +3103,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c74) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e79); } @@ -3137,12 +3136,12 @@ function peg$parse(input, options) { } function peg$parseexchangeRate() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c76) { - peg$currPos += 13; + peg$currPos += (13); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e82); } @@ -3174,12 +3173,12 @@ function peg$parse(input, options) { } function peg$parsereimbursableTotal() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); if (s1.toLowerCase() === peg$c77) { - peg$currPos += 18; + peg$currPos += (18); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e83); } @@ -3211,12 +3210,12 @@ function peg$parse(input, options) { } function peg$parsenonReimbursableTotal() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 22); if (s1.toLowerCase() === peg$c78) { - peg$currPos += 22; + peg$currPos += (22); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e84); } @@ -3248,12 +3247,12 @@ function peg$parse(input, options) { } function peg$parsegroupFrom() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c79) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e85); } @@ -3285,12 +3284,12 @@ function peg$parse(input, options) { } function peg$parsegroupExpenses() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c80) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e86); } @@ -3322,12 +3321,12 @@ function peg$parse(input, options) { } function peg$parsegroupTotal() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c81) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e87); } @@ -3359,12 +3358,12 @@ function peg$parse(input, options) { } function peg$parsegroupCard() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c82) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e88); } @@ -3396,12 +3395,12 @@ function peg$parse(input, options) { } function peg$parsegroupFeed() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c83) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e89); } @@ -3433,12 +3432,12 @@ function peg$parse(input, options) { } function peg$parsegroupBankAccount() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); if (s1.toLowerCase() === peg$c84) { - peg$currPos += 18; + peg$currPos += (18); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e90); } @@ -3470,12 +3469,12 @@ function peg$parse(input, options) { } function peg$parsegroupWithdrawn() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c85) { - peg$currPos += 15; + peg$currPos += (15); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e91); } @@ -3507,12 +3506,12 @@ function peg$parse(input, options) { } function peg$parsegroupWithdrawalId() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 19); if (s1.toLowerCase() === peg$c86) { - peg$currPos += 19; + peg$currPos += (19); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e92); } @@ -3544,12 +3543,12 @@ function peg$parse(input, options) { } function peg$parsegroupCategory() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c87) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e93); } @@ -3581,12 +3580,12 @@ function peg$parse(input, options) { } function peg$parsegroupTag() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c88) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e94); } @@ -3618,12 +3617,12 @@ function peg$parse(input, options) { } function peg$parsegroupMerchant() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c89) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e95); } @@ -3655,12 +3654,12 @@ function peg$parse(input, options) { } function peg$parsegroupMonth() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c90) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e96); } @@ -3692,12 +3691,12 @@ function peg$parse(input, options) { } function peg$parsegroupWeek() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c91) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e97); } @@ -3729,12 +3728,12 @@ function peg$parse(input, options) { } function peg$parsegroupYear() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c92) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e98); } @@ -3766,12 +3765,12 @@ function peg$parse(input, options) { } function peg$parsegroupQuarter() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c93) { - peg$currPos += 13; + peg$currPos += (13); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e99); } @@ -3803,16 +3802,16 @@ function peg$parse(input, options) { } function peg$parseoperator() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r2.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c94) { + s1 = peg$c94; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -3821,12 +3820,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c94) { - s1 = peg$c94; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r2.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -3849,9 +3848,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { + if (input.substr(peg$currPos, 2) === peg$c96) { s1 = peg$c96; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e103); } @@ -3863,9 +3862,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c97) { + if (input.charCodeAt(peg$currPos) === 62) { s1 = peg$c97; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e104); } @@ -3877,9 +3876,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { + if (input.substr(peg$currPos, 2) === peg$c98) { s1 = peg$c98; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e105); } @@ -3889,6 +3888,21 @@ function peg$parse(input, options) { s1 = peg$f93(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c99; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e106); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f94(); + } + s0 = s1; + } } } } @@ -3904,13 +3918,13 @@ function peg$parse(input, options) { } function peg$parsefilterOperator() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f94(s1); + s1 = peg$f95(s1); } s0 = s1; @@ -3918,7 +3932,7 @@ function peg$parse(input, options) { } function peg$parsealphanumeric() { - var s0, s1, s2; + let s0, s1, s2; peg$silentFails++; s0 = peg$currPos; @@ -3928,7 +3942,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -3938,7 +3952,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } } } else { @@ -3946,32 +3960,32 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f95(s1); + s1 = peg$f96(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } return s0; } function peg$parselogicalAnd() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f96(); + s1 = peg$f97(); s0 = s1; return s0; } function peg$parse_() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = []; @@ -3993,18 +4007,16 @@ function peg$parse(input, options) { } } peg$silentFails--; - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } return s0; } function peg$parsequotedString() { - var s0, s1, s2; + let s0, s1, s2; s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f97(); + s1 = peg$f98(); if (s1) { s1 = undefined; } else { @@ -4031,7 +4043,7 @@ function peg$parse(input, options) { } function peg$parsesimpleQuotedString() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -4093,7 +4105,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -4102,11 +4114,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } } peg$savedPos = s0; - s0 = peg$f98(s1, s3, s5); + s0 = peg$f99(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4125,7 +4137,7 @@ function peg$parse(input, options) { } function peg$parsenestedQuotedString() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -4177,7 +4189,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c99; + s6 = peg$c100; peg$currPos++; } else { s6 = peg$FAILED; @@ -4185,7 +4197,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f99(s1); + s4 = peg$f100(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4208,7 +4220,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c100; + s6 = peg$c101; peg$currPos++; } else { s6 = peg$FAILED; @@ -4216,7 +4228,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f100(s1); + s4 = peg$f101(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4239,7 +4251,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c101; + s6 = peg$c102; peg$currPos++; } else { s6 = peg$FAILED; @@ -4247,7 +4259,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f101(s1); + s4 = peg$f102(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4282,7 +4294,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c99; + s6 = peg$c100; peg$currPos++; } else { s6 = peg$FAILED; @@ -4290,7 +4302,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f99(s1); + s4 = peg$f100(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4313,7 +4325,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c100; + s6 = peg$c101; peg$currPos++; } else { s6 = peg$FAILED; @@ -4321,7 +4333,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f100(s1); + s4 = peg$f101(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4344,7 +4356,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c101; + s6 = peg$c102; peg$currPos++; } else { s6 = peg$FAILED; @@ -4352,7 +4364,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f101(s1); + s4 = peg$f102(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4368,7 +4380,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f102(s1, s3, s4); + s0 = peg$f103(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4387,7 +4399,7 @@ function peg$parse(input, options) { } function peg$parseclosingQuote() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.charAt(peg$currPos); @@ -4424,7 +4436,7 @@ function peg$parse(input, options) { } function peg$parsevalidClosingCondition() { - var s0, s1, s2, s3, s4; + let s0, s1, s2, s3, s4; s0 = peg$currPos; s1 = []; @@ -4562,7 +4574,7 @@ function peg$parse(input, options) { } function peg$parsewordBoundary() { - var s0, s1; + let s0, s1; s0 = input.charAt(peg$currPos); if (peg$r13.test(s0)) { @@ -4715,30 +4727,36 @@ function peg$parse(input, options) { peg$result = peg$startRuleFunction(); - if (options.peg$library) { - return /** @type {any} */ ({ - peg$result, - peg$currPos, - peg$FAILED, - peg$maxFailExpected, - peg$maxFailPos - }); - } - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { + const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); + function peg$throw() { if (peg$result !== peg$FAILED && peg$currPos < input.length) { peg$fail(peg$endExpectation()); } throw peg$buildStructuredError( peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, peg$maxFailPos < input.length ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) ); } + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos, + peg$success, + peg$throw: peg$success ? undefined : peg$throw, + }); + } + if (peg$success) { + return peg$result; + } else { + peg$throw(); + } } const peg$allowedStartRules = [ diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 75d2f61423d8..52d485bebc19 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -713,7 +713,11 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); if (keyInCorrectForm) { - return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${sanitizeSearchValue(filterValue as string)}`; + const operator = + filterKey === FILTER_KEYS.MERCHANT + ? operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS] + : operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]; + return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}${operator}${sanitizeSearchValue(filterValue as string)}`; } } if ((filterKey === FILTER_KEYS.REPORT_ID || filterKey === FILTER_KEYS.WITHDRAWAL_ID) && filterValue) { diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 7aac5ba59619..adc8cd651b8c 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -180,7 +180,7 @@ describe('SearchQueryUtils', () => { const result = buildQueryStringFromFilterFormValues(filterValues); - expect(result).toEqual('sortBy:date sortOrder:desc type:expense policyID:67890 merchant:Amazon description:Electronics laptop category:electronics,gadgets'); + expect(result).toEqual('sortBy:date sortOrder:desc type:expense policyID:67890 merchant*:Amazon description:Electronics laptop category:electronics,gadgets'); }); test('currencies and categories', () => { @@ -323,7 +323,7 @@ describe('SearchQueryUtils', () => { const result = buildQueryStringFromFilterFormValues(filterValues, {sortBy: 'amount', sortOrder: 'asc', limit: 25}); - expect(result).toEqual('sortBy:amount sortOrder:asc type:expense merchant:Amazon limit:25'); + expect(result).toEqual('sortBy:amount sortOrder:asc type:expense merchant*:Amazon limit:25'); }); test('omits limit when not provided', () => { From 167132a0739222b199831bf52bf96bb7df34ff16 Mon Sep 17 00:00:00 2001 From: Phuoc Hiep Date: Tue, 10 Mar 2026 18:16:00 +0700 Subject: [PATCH 002/781] fix: Use contains operator for merchant filter in advanced search Switch merchant filter from : (eq) to *: (contains) operator to preserve partial-matching UX when backend starts respecting eq vs contains distinction. - Add *: operator support to parser grammar - Update buildQueryStringFromFilterFormValues to use *: for merchant - Update unit test expectations Fixes https://github.com/Expensify/App/issues/80823 --- src/libs/SearchParser/autocompleteParser.js | 3270 ++++++------------- src/libs/SearchParser/baseRules.peggy | 5 +- src/libs/SearchParser/searchParser.js | 1706 +++++----- src/libs/SearchQueryUtils.ts | 9 +- tests/unit/Search/SearchQueryUtilsTest.ts | 4 +- 5 files changed, 1831 insertions(+), 3163 deletions(-) diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index e70056f0d519..8bbd481fb0ab 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -1,7 +1,6 @@ 'worklet' -class peg$SyntaxError{} -// @generated by Peggy 4.0.3. +// @generated by Peggy 5.0.6. // // https://peggyjs.org/ @@ -11,598 +10,494 @@ class peg$SyntaxError{} // To make changes, edit the corresponding *.peggy files only. // Use the `generate-search-parser` and `generate-autocomplete-parser` scripts to regenerate parsers after modifications. -function peg$subclass(child, parent) { - function C() { this.constructor = child; } - C.prototype = parent.prototype; - child.prototype = new C(); -} - -function temporary(message, expected, found, location) { - var self = Error.call(this, message); - // istanbul ignore next Check is a necessary evil to support older environments - if (Object.setPrototypeOf) { - Object.setPrototypeOf(self, peg$SyntaxError.prototype); - } - self.expected = expected; - self.found = found; - self.location = location; - self.name = "SyntaxError"; - return self; -} - - - -function peg$padEnd(str, targetLength, padString) { - padString = padString || " "; - if (str.length > targetLength) { return str; } - targetLength -= str.length; - padString += padString.repeat(targetLength); - return str + padString.slice(0, targetLength); -} +class peg$SyntaxError extends SyntaxError { + constructor(message, expected, found, location) { + super(message); + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + } + + format(sources) { + let str = "Error: " + this.message; + if (this.location) { + let src = null; + const st = sources.find(s => s.source === this.location.source); + if (st) { + src = st.text.split(/\r\n|\n|\r/g); + } + const s = this.location.start; + const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + const e = this.location.end; + const filler = "".padEnd(offset_s.line.toString().length, " "); + const line = src[s.line - 1]; + const last = s.line === e.line ? e.column : line.length + 1; + const hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + "".padEnd(s.column - 1, " ") + + "".padEnd(hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; + } + + static buildMessage(expected, found) { + function hex(ch) { + return ch.codePointAt(0).toString(16).toUpperCase(); + } + + const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") + ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") + : null; + function unicodeEscape(s) { + if (nonPrintable) { + return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); + } + return s; + } + + function literalEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + function classEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } + + const DESCRIBE_EXPECTATION_FNS = { + literal(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, -peg$SyntaxError.prototype.format = function(sources) { - var str = "Error: " + this.message; - if (this.location) { - var src = null; - var k; - for (k = 0; k < sources.length; k++) { - if (sources[k].source === this.location.source) { - src = sources[k].text.split(/\r\n|\n|\r/g); - break; - } - } - var s = this.location.start; - var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) - ? this.location.source.offset(s) - : s; - var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; - if (src) { - var e = this.location.end; - var filler = peg$padEnd("", offset_s.line.toString().length, ' '); - var line = src[s.line - 1]; - var last = s.line === e.line ? e.column : line.length + 1; - var hatLen = (last - s.column) || 1; - str += "\n --> " + loc + "\n" - + filler + " |\n" - + offset_s.line + " | " + line + "\n" - + filler + " | " + peg$padEnd("", s.column - 1, ' ') - + peg$padEnd("", hatLen, "^"); - } else { - str += "\n at " + loc; - } - } - return str; -}; + class(expectation) { + const escapedParts = expectation.parts.map( + part => (Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part)) + ); -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, - - class: function(expectation) { - var escapedParts = expectation.parts.map(function(part) { - return Array.isArray(part) - ? classEscape(part[0]) + "-" + classEscape(part[1]) - : classEscape(part); - }); + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); + }, - return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; - }, + any() { + return "any character"; + }, - any: function() { - return "any character"; - }, + end() { + return "end of input"; + }, - end: function() { - return "end of input"; - }, + other(expectation) { + return expectation.description; + }, + }; - other: function(expectation) { - return expectation.description; + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); } - }; - - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } - function literalEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } - - function classEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/\]/g, "\\]") - .replace(/\^/g, "\\^") - .replace(/-/g, "\\-") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } + function describeExpected(expected) { + const descriptions = expected.map(describeExpectation); + descriptions.sort(); - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } + if (descriptions.length > 0) { + let j = 1; + for (let i = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } - function describeExpected(expected) { - var descriptions = expected.map(describeExpectation); - var i, j; + switch (descriptions.length) { + case 1: + return descriptions[0]; - descriptions.sort(); + case 2: + return descriptions[0] + " or " + descriptions[1]; - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; } - descriptions.length = j; } - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; } - } - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; +} function peg$parse(input, options) { options = options !== undefined ? options : {}; - var peg$FAILED = {}; - var peg$source = options.grammarSource; - - var peg$startRuleFunctions = { query: peg$parsequery }; - var peg$startRuleFunction = peg$parsequery; - - var peg$c0 = "-"; - var peg$c1 = "report-field"; - var peg$c2 = "reportfield"; - var peg$c3 = ","; - var peg$c4 = "date"; - var peg$c5 = "amount"; - var peg$c6 = "merchant"; - var peg$c7 = "description"; - var peg$c8 = "reportid"; - var peg$c9 = "report-id"; - var peg$c10 = "keyword"; - var peg$c11 = "in"; - var peg$c12 = "currency"; - var peg$c13 = "groupCurrency"; - var peg$c14 = "group-currency"; - var peg$c15 = "tag"; - var peg$c16 = "category"; - var peg$c17 = "to"; - var peg$c18 = "exporter"; - var peg$c19 = "payer"; - var peg$c20 = "taxrate"; - var peg$c21 = "tax-rate"; - var peg$c22 = "cardID"; - var peg$c23 = "card"; - var peg$c24 = "from"; - var peg$c25 = "attendee"; - var peg$c26 = "expenseType"; - var peg$c27 = "expense-type"; - var peg$c28 = "withdrawalType"; - var peg$c29 = "withdrawal-type"; - var peg$c30 = "withdrawalid"; - var peg$c31 = "withdrawal-id"; - var peg$c32 = "billable"; - var peg$c33 = "reimbursable"; - var peg$c34 = "type"; - var peg$c35 = "status"; - var peg$c36 = "sortBy"; - var peg$c37 = "sort-by"; - var peg$c38 = "sortOrder"; - var peg$c39 = "sort-order"; - var peg$c40 = "policyID"; - var peg$c41 = "workspace"; - var peg$c42 = "submitted"; - var peg$c43 = "approved"; - var peg$c44 = "paid"; - var peg$c45 = "exported"; - var peg$c46 = "posted"; - var peg$c47 = "withdrawn"; - var peg$c48 = "groupby"; - var peg$c49 = "group-by"; - var peg$c50 = "limit"; - var peg$c51 = "feed"; - var peg$c52 = "title"; - var peg$c53 = "assignee"; - var peg$c54 = "createdby"; - var peg$c55 = "created-by"; - var peg$c56 = "action"; - var peg$c57 = "total"; - var peg$c58 = "has"; - var peg$c59 = "is"; - var peg$c60 = "purchaseamount"; - var peg$c61 = "purchase-amount"; - var peg$c62 = "purchasecurrency"; - var peg$c63 = "purchase-currency"; - var peg$c64 = "columns"; - var peg$c65 = "view"; - var peg$c66 = "per-diem"; - var peg$c67 = "drafts"; - var peg$c68 = "draft"; - var peg$c69 = "original-amount"; - var peg$c70 = "tax"; - var peg$c71 = "policy-name"; - var peg$c72 = "bank-account"; - var peg$c73 = "long-report-id"; - var peg$c74 = "exported-to"; - var peg$c75 = "exportedto"; - var peg$c76 = "exchange-rate"; - var peg$c77 = "reimbursable-total"; - var peg$c78 = "non-reimbursable-total"; - var peg$c79 = "group-from"; - var peg$c80 = "group-expenses"; - var peg$c81 = "group-total"; - var peg$c82 = "group-card"; - var peg$c83 = "group-feed"; - var peg$c84 = "group-bank-account"; - var peg$c85 = "group-withdrawn"; - var peg$c86 = "group-withdrawal-id"; - var peg$c87 = "group-category"; - var peg$c88 = "group-tag"; - var peg$c89 = "group-merchant"; - var peg$c90 = "group-month"; - var peg$c91 = "group-week"; - var peg$c92 = "group-year"; - var peg$c93 = "group-quarter"; - var peg$c94 = "!="; - var peg$c95 = ">="; - var peg$c96 = ">"; - var peg$c97 = "<="; - var peg$c98 = "<"; - var peg$c99 = "\u201C"; - var peg$c100 = "\u201D"; - var peg$c101 = "\""; - - var peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; - var peg$r1 = /^[:=]/; - var peg$r2 = /^[^ ,\t\n\r\xA0]/; - var peg$r3 = /^[ \t\r\n\xA0]/; - var peg$r4 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; - var peg$r5 = /^["\u201C-\u201D]/; - var peg$r6 = /^[^"\u201D\u201C\r\n]/; - var peg$r7 = /^[ \t\n\r\xA0a-zA-Z0-9]/; - var peg$r8 = /^[a-zA-Z0-9]/; - var peg$r9 = /^[ \t\n\r\xA0]/; - var peg$r10 = /^[ \t\n\r\xA0a-zA-Z]/; - var peg$r11 = /^[,]/; - var peg$r12 = /^[ \t\n\r\xA0,]/; - - var peg$e0 = peg$literalExpectation("-", false); - var peg$e1 = peg$literalExpectation("report-field", true); - var peg$e2 = peg$literalExpectation("reportfield", true); - var peg$e3 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false); - var peg$e4 = peg$anyExpectation(); - var peg$e5 = peg$literalExpectation(",", false); - var peg$e6 = peg$otherExpectation("key"); - var peg$e7 = peg$literalExpectation("date", true); - var peg$e8 = peg$literalExpectation("amount", true); - var peg$e9 = peg$literalExpectation("merchant", true); - var peg$e10 = peg$literalExpectation("description", true); - var peg$e11 = peg$literalExpectation("reportid", true); - var peg$e12 = peg$literalExpectation("report-id", true); - var peg$e13 = peg$literalExpectation("keyword", true); - var peg$e14 = peg$literalExpectation("in", true); - var peg$e15 = peg$literalExpectation("currency", true); - var peg$e16 = peg$literalExpectation("groupCurrency", false); - var peg$e17 = peg$literalExpectation("group-currency", true); - var peg$e18 = peg$literalExpectation("tag", true); - var peg$e19 = peg$literalExpectation("category", true); - var peg$e20 = peg$literalExpectation("to", true); - var peg$e21 = peg$literalExpectation("exporter", true); - var peg$e22 = peg$literalExpectation("payer", true); - var peg$e23 = peg$literalExpectation("taxRate", true); - var peg$e24 = peg$literalExpectation("tax-rate", true); - var peg$e25 = peg$literalExpectation("cardID", false); - var peg$e26 = peg$literalExpectation("card", true); - var peg$e27 = peg$literalExpectation("from", true); - var peg$e28 = peg$literalExpectation("attendee", true); - var peg$e29 = peg$literalExpectation("expenseType", false); - var peg$e30 = peg$literalExpectation("expense-type", true); - var peg$e31 = peg$literalExpectation("withdrawalType", false); - var peg$e32 = peg$literalExpectation("withdrawal-type", true); - var peg$e33 = peg$literalExpectation("withdrawalID", true); - var peg$e34 = peg$literalExpectation("withdrawal-id", true); - var peg$e35 = peg$literalExpectation("billable", true); - var peg$e36 = peg$literalExpectation("reimbursable", true); - var peg$e37 = peg$literalExpectation("type", true); - var peg$e38 = peg$literalExpectation("status", true); - var peg$e39 = peg$literalExpectation("sortBy", false); - var peg$e40 = peg$literalExpectation("sort-by", true); - var peg$e41 = peg$literalExpectation("sortOrder", false); - var peg$e42 = peg$literalExpectation("sort-order", true); - var peg$e43 = peg$literalExpectation("policyID", false); - var peg$e44 = peg$literalExpectation("workspace", true); - var peg$e45 = peg$literalExpectation("submitted", true); - var peg$e46 = peg$literalExpectation("approved", true); - var peg$e47 = peg$literalExpectation("paid", true); - var peg$e48 = peg$literalExpectation("exported", true); - var peg$e49 = peg$literalExpectation("posted", true); - var peg$e50 = peg$literalExpectation("withdrawn", true); - var peg$e51 = peg$literalExpectation("groupBy", true); - var peg$e52 = peg$literalExpectation("group-by", true); - var peg$e53 = peg$literalExpectation("limit", true); - var peg$e54 = peg$literalExpectation("feed", true); - var peg$e55 = peg$literalExpectation("title", true); - var peg$e56 = peg$literalExpectation("assignee", true); - var peg$e57 = peg$literalExpectation("createdBy", true); - var peg$e58 = peg$literalExpectation("created-by", true); - var peg$e59 = peg$literalExpectation("action", true); - var peg$e60 = peg$literalExpectation("total", true); - var peg$e61 = peg$literalExpectation("has", true); - var peg$e62 = peg$literalExpectation("is", true); - var peg$e63 = peg$literalExpectation("purchaseAmount", true); - var peg$e64 = peg$literalExpectation("purchase-amount", true); - var peg$e65 = peg$literalExpectation("purchaseCurrency", true); - var peg$e66 = peg$literalExpectation("purchase-currency", true); - var peg$e67 = peg$literalExpectation("columns", true); - var peg$e68 = peg$literalExpectation("view", true); - var peg$e69 = peg$literalExpectation("per-diem", true); - var peg$e70 = peg$literalExpectation("drafts", true); - var peg$e71 = peg$literalExpectation("draft", true); - var peg$e72 = peg$literalExpectation("original-amount", true); - var peg$e73 = peg$literalExpectation("tax", true); - var peg$e74 = peg$literalExpectation("policy-name", true); - var peg$e75 = peg$literalExpectation("bank-account", true); - var peg$e76 = peg$literalExpectation("long-report-id", true); - var peg$e77 = peg$literalExpectation("exported-to", true); - var peg$e78 = peg$classExpectation([":", "="], false, false); - var peg$e79 = peg$literalExpectation("exportedTo", true); - var peg$e80 = peg$literalExpectation("exchange-rate", true); - var peg$e81 = peg$literalExpectation("reimbursable-total", true); - var peg$e82 = peg$literalExpectation("non-reimbursable-total", true); - var peg$e83 = peg$literalExpectation("group-from", true); - var peg$e84 = peg$literalExpectation("group-expenses", true); - var peg$e85 = peg$literalExpectation("group-total", true); - var peg$e86 = peg$literalExpectation("group-card", true); - var peg$e87 = peg$literalExpectation("group-feed", true); - var peg$e88 = peg$literalExpectation("group-bank-account", true); - var peg$e89 = peg$literalExpectation("group-withdrawn", true); - var peg$e90 = peg$literalExpectation("group-withdrawal-id", true); - var peg$e91 = peg$literalExpectation("group-category", true); - var peg$e92 = peg$literalExpectation("group-tag", true); - var peg$e93 = peg$literalExpectation("group-merchant", true); - var peg$e94 = peg$literalExpectation("group-month", true); - var peg$e95 = peg$literalExpectation("group-week", true); - var peg$e96 = peg$literalExpectation("group-year", true); - var peg$e97 = peg$literalExpectation("group-quarter", true); - var peg$e98 = peg$otherExpectation("operator"); - var peg$e99 = peg$literalExpectation("!=", false); - var peg$e100 = peg$literalExpectation(">=", false); - var peg$e101 = peg$literalExpectation(">", false); - var peg$e102 = peg$literalExpectation("<=", false); - var peg$e103 = peg$literalExpectation("<", false); - var peg$e104 = peg$otherExpectation("word"); - var peg$e105 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e106 = peg$otherExpectation("whitespace"); - var peg$e107 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e108 = peg$otherExpectation("quote"); - var peg$e109 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e110 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e111 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e112 = peg$literalExpectation("\u201C", false); - var peg$e113 = peg$literalExpectation("\u201D", false); - var peg$e114 = peg$literalExpectation("\"", false); - var peg$e115 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e116 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e117 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e118 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e119 = peg$classExpectation([","], false, false); - var peg$e120 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - - var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; - var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; - var peg$f2 = function(neg, key, op, value) { - expectingNestedQuote = false; nameOperator = false; // Reset string parser - - const isNegated = !!neg && !nonNegatableKeys.has(key); - - if (!value) { - autocomplete = { - key, - negated: isNegated, - value: "", - start: location().end.offset, - length: 0, - }; - return; - } + const peg$FAILED = {}; + const peg$source = options.grammarSource; + const peg$startRuleFunctions = { + query: peg$parsequery, + }; + let peg$startRuleFunction = peg$parsequery; + + const peg$c0 = "-"; + const peg$c1 = "report-field"; + const peg$c2 = "reportfield"; + const peg$c3 = ","; + const peg$c4 = "date"; + const peg$c5 = "amount"; + const peg$c6 = "merchant"; + const peg$c7 = "description"; + const peg$c8 = "reportid"; + const peg$c9 = "report-id"; + const peg$c10 = "in"; + const peg$c11 = "currency"; + const peg$c12 = "groupCurrency"; + const peg$c13 = "group-currency"; + const peg$c14 = "tag"; + const peg$c15 = "category"; + const peg$c16 = "to"; + const peg$c17 = "exporter"; + const peg$c18 = "payer"; + const peg$c19 = "taxrate"; + const peg$c20 = "tax-rate"; + const peg$c21 = "cardID"; + const peg$c22 = "card"; + const peg$c23 = "from"; + const peg$c24 = "attendee"; + const peg$c25 = "expenseType"; + const peg$c26 = "expense-type"; + const peg$c27 = "withdrawalType"; + const peg$c28 = "withdrawal-type"; + const peg$c29 = "withdrawalid"; + const peg$c30 = "withdrawal-id"; + const peg$c31 = "billable"; + const peg$c32 = "reimbursable"; + const peg$c33 = "type"; + const peg$c34 = "status"; + const peg$c35 = "policyID"; + const peg$c36 = "workspace"; + const peg$c37 = "submitted"; + const peg$c38 = "approved"; + const peg$c39 = "paid"; + const peg$c40 = "exported"; + const peg$c41 = "posted"; + const peg$c42 = "withdrawn"; + const peg$c43 = "groupby"; + const peg$c44 = "group-by"; + const peg$c45 = "limit"; + const peg$c46 = "feed"; + const peg$c47 = "title"; + const peg$c48 = "assignee"; + const peg$c49 = "createdby"; + const peg$c50 = "created-by"; + const peg$c51 = "action"; + const peg$c52 = "total"; + const peg$c53 = "has"; + const peg$c54 = "is"; + const peg$c55 = "purchaseamount"; + const peg$c56 = "purchase-amount"; + const peg$c57 = "purchasecurrency"; + const peg$c58 = "purchase-currency"; + const peg$c59 = "columns"; + const peg$c60 = "view"; + const peg$c61 = "exportedto"; + const peg$c62 = "exported-to"; + const peg$c63 = "*:"; + const peg$c64 = "!="; + const peg$c65 = ">="; + const peg$c66 = ">"; + const peg$c67 = "<="; + const peg$c68 = "<"; + const peg$c69 = "\u201C"; + const peg$c70 = "\u201D"; + const peg$c71 = "\""; + + const peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; + const peg$r1 = /^[:=]/; + const peg$r2 = /^[^ ,\t\n\r\xA0]/; + const peg$r3 = /^[ \t\r\n\xA0]/; + const peg$r4 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; + const peg$r5 = /^["\u201C-\u201D]/; + const peg$r6 = /^[^"\u201D\u201C\r\n]/; + const peg$r7 = /^[ \t\n\r\xA0a-zA-Z0-9]/; + const peg$r8 = /^[a-zA-Z0-9]/; + const peg$r9 = /^[ \t\n\r\xA0]/; + const peg$r10 = /^[ \t\n\r\xA0a-zA-Z]/; + const peg$r11 = /^[,]/; + const peg$r12 = /^[ \t\n\r\xA0,]/; + + const peg$e0 = peg$literalExpectation("-", false); + const peg$e1 = peg$literalExpectation("report-field", true); + const peg$e2 = peg$literalExpectation("reportfield", true); + const peg$e3 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false, false); + const peg$e4 = peg$anyExpectation(); + const peg$e5 = peg$literalExpectation(",", false); + const peg$e6 = peg$otherExpectation("key"); + const peg$e7 = peg$literalExpectation("date", true); + const peg$e8 = peg$literalExpectation("amount", true); + const peg$e9 = peg$literalExpectation("merchant", true); + const peg$e10 = peg$literalExpectation("description", true); + const peg$e11 = peg$literalExpectation("reportid", true); + const peg$e12 = peg$literalExpectation("report-id", true); + const peg$e13 = peg$literalExpectation("in", true); + const peg$e14 = peg$literalExpectation("currency", true); + const peg$e15 = peg$literalExpectation("groupCurrency", false); + const peg$e16 = peg$literalExpectation("group-currency", true); + const peg$e17 = peg$literalExpectation("tag", true); + const peg$e18 = peg$literalExpectation("category", true); + const peg$e19 = peg$literalExpectation("to", true); + const peg$e20 = peg$literalExpectation("exporter", true); + const peg$e21 = peg$literalExpectation("payer", true); + const peg$e22 = peg$literalExpectation("taxRate", true); + const peg$e23 = peg$literalExpectation("tax-rate", true); + const peg$e24 = peg$literalExpectation("cardID", false); + const peg$e25 = peg$literalExpectation("card", true); + const peg$e26 = peg$literalExpectation("from", true); + const peg$e27 = peg$literalExpectation("attendee", true); + const peg$e28 = peg$literalExpectation("expenseType", false); + const peg$e29 = peg$literalExpectation("expense-type", true); + const peg$e30 = peg$literalExpectation("withdrawalType", false); + const peg$e31 = peg$literalExpectation("withdrawal-type", true); + const peg$e32 = peg$literalExpectation("withdrawalID", true); + const peg$e33 = peg$literalExpectation("withdrawal-id", true); + const peg$e34 = peg$literalExpectation("billable", true); + const peg$e35 = peg$literalExpectation("reimbursable", true); + const peg$e36 = peg$literalExpectation("type", true); + const peg$e37 = peg$literalExpectation("status", true); + const peg$e38 = peg$literalExpectation("policyID", false); + const peg$e39 = peg$literalExpectation("workspace", true); + const peg$e40 = peg$literalExpectation("submitted", true); + const peg$e41 = peg$literalExpectation("approved", true); + const peg$e42 = peg$literalExpectation("paid", true); + const peg$e43 = peg$literalExpectation("exported", true); + const peg$e44 = peg$literalExpectation("posted", true); + const peg$e45 = peg$literalExpectation("withdrawn", true); + const peg$e46 = peg$literalExpectation("groupBy", true); + const peg$e47 = peg$literalExpectation("group-by", true); + const peg$e48 = peg$literalExpectation("limit", true); + const peg$e49 = peg$literalExpectation("feed", true); + const peg$e50 = peg$literalExpectation("title", true); + const peg$e51 = peg$literalExpectation("assignee", true); + const peg$e52 = peg$literalExpectation("createdBy", true); + const peg$e53 = peg$literalExpectation("created-by", true); + const peg$e54 = peg$literalExpectation("action", true); + const peg$e55 = peg$literalExpectation("total", true); + const peg$e56 = peg$literalExpectation("has", true); + const peg$e57 = peg$literalExpectation("is", true); + const peg$e58 = peg$literalExpectation("purchaseAmount", true); + const peg$e59 = peg$literalExpectation("purchase-amount", true); + const peg$e60 = peg$literalExpectation("purchaseCurrency", true); + const peg$e61 = peg$literalExpectation("purchase-currency", true); + const peg$e62 = peg$literalExpectation("columns", true); + const peg$e63 = peg$literalExpectation("view", true); + const peg$e64 = peg$classExpectation([":", "="], false, false, false); + const peg$e65 = peg$literalExpectation("exportedTo", true); + const peg$e66 = peg$literalExpectation("exported-to", true); + const peg$e67 = peg$otherExpectation("operator"); + const peg$e68 = peg$literalExpectation("*:", false); + const peg$e69 = peg$literalExpectation("!=", false); + const peg$e70 = peg$literalExpectation(">=", false); + const peg$e71 = peg$literalExpectation(">", false); + const peg$e72 = peg$literalExpectation("<=", false); + const peg$e73 = peg$literalExpectation("<", false); + const peg$e74 = peg$otherExpectation("word"); + const peg$e75 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false, false); + const peg$e76 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false, false); + const peg$e77 = peg$otherExpectation("quote"); + const peg$e78 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false, false); + const peg$e79 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false, false); + const peg$e80 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false, false); + const peg$e81 = peg$literalExpectation("\u201C", false); + const peg$e82 = peg$literalExpectation("\u201D", false); + const peg$e83 = peg$literalExpectation("\"", false); + const peg$e84 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); + const peg$e85 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); + const peg$e86 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false, false); + const peg$e87 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false, false); + const peg$e88 = peg$classExpectation([","], false, false, false); + const peg$e89 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false, false); + + function peg$f0(ranges) { return { autocomplete, ranges }; } + function peg$f1(filters) { return filters.filter(Boolean).flat(); } + function peg$f2(neg, key, op, value) { + expectingNestedQuote = false; nameOperator = false; // Reset string parser + + const isNegated = !!neg && !nonNegatableKeys.has(key); + + if (!value) { autocomplete = { key, negated: isNegated, - ...value[value.length - 1], + value: "", + start: location().end.offset, + length: 0, }; - return value - .filter((v) => v.length > 0) - .map((v) => ({ - key, - negated: isNegated, - ...v, - })); - }; - var peg$f3 = function(rest) { - const suffix = rest.replaceAll(/^-+/g, ""); - return "reportField-" + (suffix ? suffix : ""); - }; - var peg$f4 = function() { autocomplete = null; }; - var peg$f5 = function(k) { - nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); - return k; + return; + } + + autocomplete = { + key, + negated: isNegated, + ...value[value.length - 1], }; - var peg$f6 = function(parts, empty) { - const ends = location(); - const value = parts.flat().filter(Boolean); // Filter out undefined values returned by the predicate - if (empty) { - value.push(""); - } - let count = ends.start.offset; - const result = []; - value.forEach((filter) => { - let word = filter; - if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { - word = word.slice(1, -1); - } - result.push({ - value: word, - start: count, - length: filter.length, - }); - count += filter.length + 1; + return value + .filter((v) => v.length > 0) + .map((v) => ({ + key, + negated: isNegated, + ...v, + })); + } + function peg$f3(rest) { + const suffix = rest.replaceAll(/^-+/g, ""); + return "reportField-" + (suffix ? suffix : ""); + } + function peg$f4() { autocomplete = null; } + function peg$f5(k) { + nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); + return k; + } + function peg$f6(parts, empty) { + const ends = location(); + const value = parts.flat().filter(Boolean); // Filter out undefined values returned by the predicate + if (empty) { + value.push(""); + } + let count = ends.start.offset; + const result = []; + value.forEach((filter) => { + let word = filter; + if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { + word = word.slice(1, -1); + } + result.push({ + value: word, + start: count, + length: filter.length, }); - return result; - }; - var peg$f7 = function() { return "date"; }; - var peg$f8 = function() { return "amount"; }; - var peg$f9 = function() { return "merchant"; }; - var peg$f10 = function() { return "description"; }; - var peg$f11 = function() { return "reportID"; }; - var peg$f12 = function() { return "keyword"; }; - var peg$f13 = function() { return "in"; }; - var peg$f14 = function() { return "currency"; }; - var peg$f15 = function() { return "groupCurrency"; }; - var peg$f16 = function() { return "tag"; }; - var peg$f17 = function() { return "category"; }; - var peg$f18 = function() { return "to"; }; - var peg$f19 = function() { return "exporter"; }; - var peg$f20 = function() { return "payer"; }; - var peg$f21 = function() { return "taxRate"; }; - var peg$f22 = function() { return "cardID"; }; - var peg$f23 = function() { return "from"; }; - var peg$f24 = function() {return "attendee"}; - var peg$f25 = function() { return "expenseType"; }; - var peg$f26 = function() { return "withdrawalType"; }; - var peg$f27 = function() { return "withdrawalID"; }; - var peg$f28 = function() { return "billable"; }; - var peg$f29 = function() { return "reimbursable"; }; - var peg$f30 = function() { return "type"; }; - var peg$f31 = function() { return "status"; }; - var peg$f32 = function() { return "sortBy"; }; - var peg$f33 = function() { return "sortOrder"; }; - var peg$f34 = function() { return "policyID"; }; - var peg$f35 = function() { return "submitted"; }; - var peg$f36 = function() { return "approved"; }; - var peg$f37 = function() { return "paid"; }; - var peg$f38 = function() { return "exported"; }; - var peg$f39 = function() { return "posted"; }; - var peg$f40 = function() { return "withdrawn"; }; - var peg$f41 = function() { return "groupBy"; }; - var peg$f42 = function() { return "limit"; }; - var peg$f43 = function() { return "feed"; }; - var peg$f44 = function() { return "title"; }; - var peg$f45 = function() { return "assignee"; }; - var peg$f46 = function() { return "createdBy"; }; - var peg$f47 = function() { return "action"; }; - var peg$f48 = function() {return "total"; }; - var peg$f49 = function() {return "has"; }; - var peg$f50 = function() {return "is"; }; - var peg$f51 = function() {return "purchaseAmount"}; - var peg$f52 = function() {return "purchaseCurrency"}; - var peg$f53 = function() { - isColumnsContext = true; - return "columns"; -}; - var peg$f54 = function() {return "view"}; - var peg$f55 = function() { return isColumnsContext; }; - var peg$f56 = function() { return "perDiem"; }; - var peg$f57 = function() { return "drafts"; }; - var peg$f58 = function() { return "originalamount"; }; - var peg$f59 = function() { return "taxAmount"; }; - var peg$f60 = function() { return "taxrate"; }; - var peg$f61 = function() { return "policyname"; }; - var peg$f62 = function() { return "withdrawalID"; }; - var peg$f63 = function() { return "bankAccount"; }; - var peg$f64 = function() { return "reportID"; }; - var peg$f65 = function() { return "base62ReportID"; }; - var peg$f66 = function() { return "exportedto"; }; - var peg$f67 = function() { return "exportedTo"; }; - var peg$f68 = function() { return "exportedTo"; }; - var peg$f69 = function() { return "exchangeRate"; }; - var peg$f70 = function() { return "reimbursableTotal"; }; - var peg$f71 = function() { return "nonReimbursableTotal"; }; - var peg$f72 = function() { return "groupFrom"; }; - var peg$f73 = function() { return "groupExpenses"; }; - var peg$f74 = function() { return "groupTotal"; }; - var peg$f75 = function() { return "groupCard"; }; - var peg$f76 = function() { return "groupFeed"; }; - var peg$f77 = function() { return "groupBankAccount"; }; - var peg$f78 = function() { return "groupWithdrawn"; }; - var peg$f79 = function() { return "groupWithdrawalID"; }; - var peg$f80 = function() { return "groupCategory"; }; - var peg$f81 = function() { return "groupTag"; }; - var peg$f82 = function() { return "groupMerchant"; }; - var peg$f83 = function() { return "groupmonth"; }; - var peg$f84 = function() { return "groupweek"; }; - var peg$f85 = function() { return "groupyear"; }; - var peg$f86 = function() { return "groupquarter"; }; - var peg$f87 = function() { return "eq"; }; - var peg$f88 = function() { return "neq"; }; - var peg$f89 = function() { return "gte"; }; - var peg$f90 = function() { return "gt"; }; - var peg$f91 = function() { return "lte"; }; - var peg$f92 = function() { return "lt"; }; - var peg$f93 = function(o) { - if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found - } - isColumnsContext = false; - return o; - }; - var peg$f94 = function(chars) { return chars.join("").trim(); }; - var peg$f95 = function() { - isColumnsContext = false; - return "and"; -}; - var peg$f96 = function() { return expectingNestedQuote; }; - var peg$f97 = function(start, inner, end) { //handle no-breaking space - return [...start, '"', ...inner, '"', ...end].join(""); - }; - var peg$f98 = function(start) {return "“"}; - var peg$f99 = function(start) {return "”"}; - var peg$f100 = function(start) {return "\""}; - var peg$f101 = function(start, inner, end) { + count += filter.length + 1; + }); + return result; + } + function peg$f7() { return "date"; } + function peg$f8() { return "amount"; } + function peg$f9() { return "merchant"; } + function peg$f10() { return "description"; } + function peg$f11() { return "reportID"; } + function peg$f12() { return "in"; } + function peg$f13() { return "currency"; } + function peg$f14() { return "groupCurrency"; } + function peg$f15() { return "tag"; } + function peg$f16() { return "category"; } + function peg$f17() { return "to"; } + function peg$f18() { return "exporter"; } + function peg$f19() { return "payer"; } + function peg$f20() { return "taxRate"; } + function peg$f21() { return "cardID"; } + function peg$f22() { return "from"; } + function peg$f23() {return "attendee" } + function peg$f24() { return "expenseType"; } + function peg$f25() { return "withdrawalType"; } + function peg$f26() { return "withdrawalID"; } + function peg$f27() { return "billable"; } + function peg$f28() { return "reimbursable"; } + function peg$f29() { return "type"; } + function peg$f30() { return "status"; } + function peg$f31() { return "policyID"; } + function peg$f32() { return "submitted"; } + function peg$f33() { return "approved"; } + function peg$f34() { return "paid"; } + function peg$f35() { return "exported"; } + function peg$f36() { return "posted"; } + function peg$f37() { return "withdrawn"; } + function peg$f38() { return "groupBy"; } + function peg$f39() { return "limit"; } + function peg$f40() { return "feed"; } + function peg$f41() { return "title"; } + function peg$f42() { return "assignee"; } + function peg$f43() { return "createdBy"; } + function peg$f44() { return "action"; } + function peg$f45() {return "total"; } + function peg$f46() {return "has"; } + function peg$f47() {return "is"; } + function peg$f48() {return "purchaseAmount" } + function peg$f49() {return "purchaseCurrency" } + function peg$f50() { + isColumnsContext = true; + return "columns"; + } + function peg$f51() {return "view" } + function peg$f52() { return "exportedTo"; } + function peg$f53() { return "exportedTo"; } + function peg$f54() { return "contains"; } + function peg$f55() { return "eq"; } + function peg$f56() { return "neq"; } + function peg$f57() { return "gte"; } + function peg$f58() { return "gt"; } + function peg$f59() { return "lte"; } + function peg$f60() { return "lt"; } + function peg$f61(o) { + if (nameOperator) { + expectingNestedQuote = (o === "eq" || o === "contains"); + } + isColumnsContext = false; + return o; + } + function peg$f62(chars) { return chars.join("").trim(); } + function peg$f63() { + isColumnsContext = false; + return "and"; + } + function peg$f64() { return expectingNestedQuote; } + function peg$f65(start, inner, end) { //handle no-breaking space + return [...start, '"', ...inner, '"', ...end].join(""); + } + function peg$f66(start) {return "“" } + function peg$f67(start) {return "”" } + function peg$f68(start) {return "\"" } + function peg$f69(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); -}; - var peg$currPos = options.peg$currPos | 0; - var peg$savedPos = peg$currPos; - var peg$posDetailsCache = [{ line: 1, column: 1 }]; - var peg$maxFailPos = peg$currPos; - var peg$maxFailExpected = options.peg$maxFailExpected || []; - var peg$silentFails = options.peg$silentFails | 0; + } + let peg$currPos = options.peg$currPos | 0; + let peg$savedPos = peg$currPos; + const peg$posDetailsCache = [{ line: 1, column: 1 }]; + let peg$maxFailPos = peg$currPos; + let peg$maxFailExpected = options.peg$maxFailExpected || []; + let peg$silentFails = options.peg$silentFails | 0; - var peg$result; + let peg$result; if (options.startRule) { if (!(options.startRule in peg$startRuleFunctions)) { @@ -624,7 +519,7 @@ function peg$parse(input, options) { return { source: peg$source, start: peg$savedPos, - end: peg$currPos + end: peg$currPos, }; } @@ -652,12 +547,20 @@ function peg$parse(input, options) { throw peg$buildSimpleError(message, location); } + function peg$getUnicode(pos = peg$currPos) { + const cp = input.codePointAt(pos); + if (cp === undefined) { + return ""; + } + return String.fromCodePoint(cp); + } + function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; + return { type: "literal", text, ignoreCase }; } - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + function peg$classExpectation(parts, inverted, ignoreCase, unicode) { + return { type: "class", parts, inverted, ignoreCase, unicode }; } function peg$anyExpectation() { @@ -669,12 +572,12 @@ function peg$parse(input, options) { } function peg$otherExpectation(description) { - return { type: "other", description: description }; + return { type: "other", description }; } function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos]; - var p; + let details = peg$posDetailsCache[pos]; + let p; if (details) { return details; @@ -689,7 +592,7 @@ function peg$parse(input, options) { details = peg$posDetailsCache[p]; details = { line: details.line, - column: details.column + column: details.column, }; while (p < pos) { @@ -710,21 +613,21 @@ function peg$parse(input, options) { } function peg$computeLocation(startPos, endPos, offset) { - var startPosDetails = peg$computePosDetails(startPos); - var endPosDetails = peg$computePosDetails(endPos); + const startPosDetails = peg$computePosDetails(startPos); + const endPosDetails = peg$computePosDetails(endPos); - var res = { + const res = { source: peg$source, start: { offset: startPos, line: startPosDetails.line, - column: startPosDetails.column + column: startPosDetails.column, }, end: { offset: endPos, line: endPosDetails.line, - column: endPosDetails.column - } + column: endPosDetails.column, + }, }; if (offset && peg$source && (typeof peg$source.offset === "function")) { res.start = peg$source.offset(res.start); @@ -758,7 +661,7 @@ function peg$parse(input, options) { } function peg$parsequery() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -771,7 +674,7 @@ function peg$parse(input, options) { } function peg$parsefilterList() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = []; @@ -796,7 +699,7 @@ function peg$parse(input, options) { } function peg$parsefilter() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parsedefaultFilter(); @@ -814,7 +717,7 @@ function peg$parse(input, options) { } function peg$parsedefaultFilter() { - var s0, s1, s2, s3, s4, s5, s6, s7; + let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = peg$parse_(); @@ -853,12 +756,12 @@ function peg$parse(input, options) { } function peg$parsereportFieldDynamic() { - var s0, s1, s2, s3, s4, s5, s6, s7; + let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c1) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e1); } @@ -866,7 +769,7 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c2) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e2); } @@ -974,7 +877,7 @@ function peg$parse(input, options) { } function peg$parsefreeTextFilter() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -1001,7 +904,7 @@ function peg$parse(input, options) { } function peg$parseautocompleteKey() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -1160,7 +1063,7 @@ function peg$parse(input, options) { } function peg$parsefilterKey() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parseautocompleteKey(); @@ -1174,7 +1077,7 @@ function peg$parse(input, options) { } function peg$parseidentifier() { - var s0, s1, s2, s3, s4; + let s0, s1, s2, s3, s4; s0 = peg$currPos; s1 = peg$currPos; @@ -1236,12 +1139,12 @@ function peg$parse(input, options) { } function peg$parsedate() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c4) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e7); } @@ -1256,12 +1159,12 @@ function peg$parse(input, options) { } function peg$parseamount() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c5) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e8); } @@ -1276,12 +1179,12 @@ function peg$parse(input, options) { } function peg$parsemerchant() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c6) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e9); } @@ -1296,12 +1199,12 @@ function peg$parse(input, options) { } function peg$parsedescription() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c7) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e10); } @@ -1316,11 +1219,11 @@ function peg$parse(input, options) { } function peg$parsereportID() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 8); if (s0.toLowerCase() === peg$c8) { - peg$currPos += 8; + peg$currPos += (8); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e11); } @@ -1329,7 +1232,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c9) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e12); } @@ -1344,13 +1247,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsekeyword() { - var s0, s1; + function peg$parsein() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 7); + s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c10) { - peg$currPos += 7; + peg$currPos += (2); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e13); } @@ -1364,13 +1267,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsein() { - var s0, s1; + function peg$parsecurrency() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 2); + s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c11) { - peg$currPos += 2; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e14); } @@ -1384,48 +1287,28 @@ function peg$parse(input, options) { return s0; } - function peg$parsecurrency() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c12) { - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e15); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f14(); - } - s0 = s1; - - return s0; - } - function peg$parsegroupCurrency() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 13) === peg$c13) { - s0 = peg$c13; + if (input.substr(peg$currPos, 13) === peg$c12) { + s0 = peg$c12; peg$currPos += 13; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e16); } + if (peg$silentFails === 0) { peg$fail(peg$e15); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c14) { - peg$currPos += 14; + if (s1.toLowerCase() === peg$c13) { + peg$currPos += (14); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e17); } + if (peg$silentFails === 0) { peg$fail(peg$e16); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f15(); + s1 = peg$f14(); } s0 = s1; } @@ -1434,19 +1317,19 @@ function peg$parse(input, options) { } function peg$parsetag() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c15) { - peg$currPos += 3; + if (s1.toLowerCase() === peg$c14) { + peg$currPos += (3); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e18); } + if (peg$silentFails === 0) { peg$fail(peg$e17); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f16(); + s1 = peg$f15(); } s0 = s1; @@ -1454,19 +1337,19 @@ function peg$parse(input, options) { } function peg$parsecategory() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c16) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c15) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } + if (peg$silentFails === 0) { peg$fail(peg$e18); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f17(); + s1 = peg$f16(); } s0 = s1; @@ -1474,19 +1357,19 @@ function peg$parse(input, options) { } function peg$parseto() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); - if (s1.toLowerCase() === peg$c17) { - peg$currPos += 2; + if (s1.toLowerCase() === peg$c16) { + peg$currPos += (2); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e19); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f18(); + s1 = peg$f17(); } s0 = s1; @@ -1494,19 +1377,19 @@ function peg$parse(input, options) { } function peg$parseexporter() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c18) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c17) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } + if (peg$silentFails === 0) { peg$fail(peg$e20); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f19(); + s1 = peg$f18(); } s0 = s1; @@ -1514,19 +1397,19 @@ function peg$parse(input, options) { } function peg$parsepayer() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c19) { - peg$currPos += 5; + if (s1.toLowerCase() === peg$c18) { + peg$currPos += (5); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f20(); + s1 = peg$f19(); } s0 = s1; @@ -1534,27 +1417,27 @@ function peg$parse(input, options) { } function peg$parsetaxRate() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 7); - if (s0.toLowerCase() === peg$c20) { - peg$currPos += 7; + if (s0.toLowerCase() === peg$c19) { + peg$currPos += (7); } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c21) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c20) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } + if (peg$silentFails === 0) { peg$fail(peg$e23); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f21(); + s1 = peg$f20(); } s0 = s1; } @@ -1563,27 +1446,27 @@ function peg$parse(input, options) { } function peg$parsecardID() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 6) === peg$c22) { - s0 = peg$c22; + if (input.substr(peg$currPos, 6) === peg$c21) { + s0 = peg$c21; peg$currPos += 6; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e25); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c23) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c22) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e25); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f22(); + s1 = peg$f21(); } s0 = s1; } @@ -1592,19 +1475,19 @@ function peg$parse(input, options) { } function peg$parsefrom() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c24) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c23) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e27); } + if (peg$silentFails === 0) { peg$fail(peg$e26); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f23(); + s1 = peg$f22(); } s0 = s1; @@ -1612,19 +1495,19 @@ function peg$parse(input, options) { } function peg$parseattendee() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c25) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c24) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f24(); + s1 = peg$f23(); } s0 = s1; @@ -1632,27 +1515,27 @@ function peg$parse(input, options) { } function peg$parseexpenseType() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 11) === peg$c26) { - s0 = peg$c26; + if (input.substr(peg$currPos, 11) === peg$c25) { + s0 = peg$c25; peg$currPos += 11; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } + if (peg$silentFails === 0) { peg$fail(peg$e28); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c27) { - peg$currPos += 12; + if (s1.toLowerCase() === peg$c26) { + peg$currPos += (12); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e30); } + if (peg$silentFails === 0) { peg$fail(peg$e29); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f25(); + s1 = peg$f24(); } s0 = s1; } @@ -1661,27 +1544,27 @@ function peg$parse(input, options) { } function peg$parsewithdrawalType() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 14) === peg$c28) { - s0 = peg$c28; + if (input.substr(peg$currPos, 14) === peg$c27) { + s0 = peg$c27; peg$currPos += 14; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e31); } + if (peg$silentFails === 0) { peg$fail(peg$e30); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c29) { - peg$currPos += 15; + if (s1.toLowerCase() === peg$c28) { + peg$currPos += (15); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e32); } + if (peg$silentFails === 0) { peg$fail(peg$e31); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f26(); + s1 = peg$f25(); } s0 = s1; } @@ -1690,27 +1573,27 @@ function peg$parse(input, options) { } function peg$parsewithdrawalID() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 12); - if (s0.toLowerCase() === peg$c30) { - peg$currPos += 12; + if (s0.toLowerCase() === peg$c29) { + peg$currPos += (12); } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e33); } + if (peg$silentFails === 0) { peg$fail(peg$e32); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c31) { - peg$currPos += 13; + if (s1.toLowerCase() === peg$c30) { + peg$currPos += (13); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e34); } + if (peg$silentFails === 0) { peg$fail(peg$e33); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f27(); + s1 = peg$f26(); } s0 = s1; } @@ -1719,19 +1602,19 @@ function peg$parse(input, options) { } function peg$parsebillable() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c32) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c31) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e35); } + if (peg$silentFails === 0) { peg$fail(peg$e34); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f28(); + s1 = peg$f27(); } s0 = s1; @@ -1739,19 +1622,19 @@ function peg$parse(input, options) { } function peg$parsereimbursable() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c33) { - peg$currPos += 12; + if (s1.toLowerCase() === peg$c32) { + peg$currPos += (12); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e36); } + if (peg$silentFails === 0) { peg$fail(peg$e35); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f29(); + s1 = peg$f28(); } s0 = s1; @@ -1759,19 +1642,19 @@ function peg$parse(input, options) { } function peg$parsetype() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c34) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c33) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e37); } + if (peg$silentFails === 0) { peg$fail(peg$e36); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f30(); + s1 = peg$f29(); } s0 = s1; @@ -1779,105 +1662,47 @@ function peg$parse(input, options) { } function peg$parsestatus() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c35) { - peg$currPos += 6; + if (s1.toLowerCase() === peg$c34) { + peg$currPos += (6); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e38); } + if (peg$silentFails === 0) { peg$fail(peg$e37); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f31(); + s1 = peg$f30(); } s0 = s1; return s0; } - function peg$parsesortBy() { - var s0, s1; - - if (input.substr(peg$currPos, 6) === peg$c36) { - s0 = peg$c36; - peg$currPos += 6; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e39); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c37) { - peg$currPos += 7; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e40); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f32(); - } - s0 = s1; - } - - return s0; - } - - function peg$parsesortOrder() { - var s0, s1; - - if (input.substr(peg$currPos, 9) === peg$c38) { - s0 = peg$c38; - peg$currPos += 9; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e41); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c39) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e42); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f33(); - } - s0 = s1; - } - - return s0; - } - function peg$parsepolicyID() { - var s0, s1; + let s0, s1; - if (input.substr(peg$currPos, 8) === peg$c40) { - s0 = peg$c40; + if (input.substr(peg$currPos, 8) === peg$c35) { + s0 = peg$c35; peg$currPos += 8; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e43); } + if (peg$silentFails === 0) { peg$fail(peg$e38); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c41) { - peg$currPos += 9; + if (s1.toLowerCase() === peg$c36) { + peg$currPos += (9); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e44); } + if (peg$silentFails === 0) { peg$fail(peg$e39); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f34(); + s1 = peg$f31(); } s0 = s1; } @@ -1886,19 +1711,19 @@ function peg$parse(input, options) { } function peg$parsesubmitted() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c42) { - peg$currPos += 9; + if (s1.toLowerCase() === peg$c37) { + peg$currPos += (9); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e45); } + if (peg$silentFails === 0) { peg$fail(peg$e40); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f35(); + s1 = peg$f32(); } s0 = s1; @@ -1906,19 +1731,19 @@ function peg$parse(input, options) { } function peg$parseapproved() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c43) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c38) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e46); } + if (peg$silentFails === 0) { peg$fail(peg$e41); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f36(); + s1 = peg$f33(); } s0 = s1; @@ -1926,19 +1751,19 @@ function peg$parse(input, options) { } function peg$parsepaid() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c44) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c39) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e47); } + if (peg$silentFails === 0) { peg$fail(peg$e42); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f37(); + s1 = peg$f34(); } s0 = s1; @@ -1946,19 +1771,19 @@ function peg$parse(input, options) { } function peg$parseexported() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c45) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c40) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e48); } + if (peg$silentFails === 0) { peg$fail(peg$e43); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f38(); + s1 = peg$f35(); } s0 = s1; @@ -1966,19 +1791,19 @@ function peg$parse(input, options) { } function peg$parseposted() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c46) { - peg$currPos += 6; + if (s1.toLowerCase() === peg$c41) { + peg$currPos += (6); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e49); } + if (peg$silentFails === 0) { peg$fail(peg$e44); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f39(); + s1 = peg$f36(); } s0 = s1; @@ -1986,19 +1811,19 @@ function peg$parse(input, options) { } function peg$parsewithdrawn() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c47) { - peg$currPos += 9; + if (s1.toLowerCase() === peg$c42) { + peg$currPos += (9); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e50); } + if (peg$silentFails === 0) { peg$fail(peg$e45); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f40(); + s1 = peg$f37(); } s0 = s1; @@ -2006,27 +1831,27 @@ function peg$parse(input, options) { } function peg$parsegroupBy() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 7); - if (s0.toLowerCase() === peg$c48) { - peg$currPos += 7; + if (s0.toLowerCase() === peg$c43) { + peg$currPos += (7); } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e51); } + if (peg$silentFails === 0) { peg$fail(peg$e46); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c49) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c44) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e52); } + if (peg$silentFails === 0) { peg$fail(peg$e47); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f41(); + s1 = peg$f38(); } s0 = s1; } @@ -2035,19 +1860,19 @@ function peg$parse(input, options) { } function peg$parselimit() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c50) { - peg$currPos += 5; + if (s1.toLowerCase() === peg$c45) { + peg$currPos += (5); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e53); } + if (peg$silentFails === 0) { peg$fail(peg$e48); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f42(); + s1 = peg$f39(); } s0 = s1; @@ -2055,19 +1880,19 @@ function peg$parse(input, options) { } function peg$parsefeed() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c51) { - peg$currPos += 4; + if (s1.toLowerCase() === peg$c46) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e54); } + if (peg$silentFails === 0) { peg$fail(peg$e49); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f43(); + s1 = peg$f40(); } s0 = s1; @@ -2075,19 +1900,19 @@ function peg$parse(input, options) { } function peg$parsetitle() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c52) { - peg$currPos += 5; + if (s1.toLowerCase() === peg$c47) { + peg$currPos += (5); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e55); } + if (peg$silentFails === 0) { peg$fail(peg$e50); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f44(); + s1 = peg$f41(); } s0 = s1; @@ -2095,19 +1920,19 @@ function peg$parse(input, options) { } function peg$parseassignee() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c53) { - peg$currPos += 8; + if (s1.toLowerCase() === peg$c48) { + peg$currPos += (8); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e56); } + if (peg$silentFails === 0) { peg$fail(peg$e51); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f45(); + s1 = peg$f42(); } s0 = s1; @@ -2115,27 +1940,27 @@ function peg$parse(input, options) { } function peg$parsecreatedBy() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 9); - if (s0.toLowerCase() === peg$c54) { - peg$currPos += 9; + if (s0.toLowerCase() === peg$c49) { + peg$currPos += (9); } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e57); } + if (peg$silentFails === 0) { peg$fail(peg$e52); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c55) { - peg$currPos += 10; + if (s1.toLowerCase() === peg$c50) { + peg$currPos += (10); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e58); } + if (peg$silentFails === 0) { peg$fail(peg$e53); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f46(); + s1 = peg$f43(); } s0 = s1; } @@ -2144,19 +1969,19 @@ function peg$parse(input, options) { } function peg$parseaction() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c56) { - peg$currPos += 6; + if (s1.toLowerCase() === peg$c51) { + peg$currPos += (6); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e59); } + if (peg$silentFails === 0) { peg$fail(peg$e54); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f47(); + s1 = peg$f44(); } s0 = s1; @@ -2164,1438 +1989,204 @@ function peg$parse(input, options) { } function peg$parsetotal() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c57) { - peg$currPos += 5; + if (s1.toLowerCase() === peg$c52) { + peg$currPos += (5); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e60); } + if (peg$silentFails === 0) { peg$fail(peg$e55); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f48(); - } - s0 = s1; - - return s0; - } - - function peg$parsehas() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c58) { - peg$currPos += 3; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e61); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f49(); - } - s0 = s1; - - return s0; - } - - function peg$parseis() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 2); - if (s1.toLowerCase() === peg$c59) { - peg$currPos += 2; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e62); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f50(); - } - s0 = s1; - - return s0; - } - - function peg$parsepurchaseAmount() { - var s0, s1; - - s0 = input.substr(peg$currPos, 14); - if (s0.toLowerCase() === peg$c60) { - peg$currPos += 14; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e63); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c61) { - peg$currPos += 15; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e64); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f51(); - } - s0 = s1; - } - - return s0; - } - - function peg$parsepurchaseCurrency() { - var s0, s1; - - s0 = input.substr(peg$currPos, 16); - if (s0.toLowerCase() === peg$c62) { - peg$currPos += 16; - } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e65); } - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c63) { - peg$currPos += 17; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e66); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f52(); - } - s0 = s1; - } - - return s0; - } - - function peg$parsecolumns() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c64) { - peg$currPos += 7; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e67); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f53(); - } - s0 = s1; - - return s0; - } - - function peg$parseview() { - var s0, s1; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c65) { - peg$currPos += 4; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e68); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f54(); - } - s0 = s1; - - return s0; - } - - function peg$parsevalues() { - var s0, s1, s2; - - s0 = peg$currPos; - peg$savedPos = peg$currPos; - s1 = peg$f55(); - if (s1) { - s1 = undefined; - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - s2 = peg$parsecolumnsValues(); - if (s2 !== peg$FAILED) { - s1 = [s1, s2]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$parseperDiem(); - if (s0 === peg$FAILED) { - s0 = peg$parsedraft(); - } - } - - return s0; - } - - function peg$parsecolumnsValues() { - var s0; - - s0 = peg$parseoriginalAmount(); - if (s0 === peg$FAILED) { - s0 = peg$parsetaxRateValue(); - if (s0 === peg$FAILED) { - s0 = peg$parsetaxAmount(); - if (s0 === peg$FAILED) { - s0 = peg$parsepolicyName(); - if (s0 === peg$FAILED) { - s0 = peg$parsewithdrawalId(); - if (s0 === peg$FAILED) { - s0 = peg$parsebankAccount(); - if (s0 === peg$FAILED) { - s0 = peg$parsereportId(); - if (s0 === peg$FAILED) { - s0 = peg$parsebase62ReportId(); - if (s0 === peg$FAILED) { - s0 = peg$parseexportedToColumnValue(); - if (s0 === peg$FAILED) { - s0 = peg$parseexchangeRate(); - if (s0 === peg$FAILED) { - s0 = peg$parsereimbursableTotal(); - if (s0 === peg$FAILED) { - s0 = peg$parsenonReimbursableTotal(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupFrom(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupExpenses(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupTotal(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupCard(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupFeed(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupBankAccount(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupWithdrawn(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupWithdrawalId(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupCategory(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupTag(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupMerchant(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupMonth(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupWeek(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupYear(); - if (s0 === peg$FAILED) { - s0 = peg$parsegroupQuarter(); - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - - return s0; - } - - function peg$parseperDiem() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c66) { - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e69); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f56(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsedraft() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c67) { - peg$currPos += 6; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e70); } - } - if (s1 === peg$FAILED) { - s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c68) { - peg$currPos += 5; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e71); } - } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f57(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseoriginalAmount() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c69) { - peg$currPos += 15; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e72); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f58(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsetaxAmount() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c70) { - peg$currPos += 3; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e73); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f59(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsetaxRateValue() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c21) { - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f60(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsepolicyName() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c71) { - peg$currPos += 11; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e74); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f61(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsewithdrawalId() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c31) { - peg$currPos += 13; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e34); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f62(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsebankAccount() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c72) { - peg$currPos += 12; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e75); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f63(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsereportId() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c73) { - peg$currPos += 14; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e76); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f64(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsebase62ReportId() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c9) { - peg$currPos += 9; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e12); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f65(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseexportedToColumnValue() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c74) { - peg$currPos += 11; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e77); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f66(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseexportedToKeyEnd() { - var s0, s1, s2; - - s0 = peg$parsewordBoundary(); - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parse_(); - s2 = input.charAt(peg$currPos); - if (peg$r1.test(s2)) { - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } - } - if (s2 !== peg$FAILED) { - s1 = [s1, s2]; - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseexportedTo() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c75) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e79); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parseexportedToKeyEnd(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f67(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c74) { - peg$currPos += 11; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e77); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parseexportedToKeyEnd(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f68(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } - - return s0; - } - - function peg$parseexchangeRate() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c76) { - peg$currPos += 13; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f69(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsereimbursableTotal() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c77) { - peg$currPos += 18; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e81); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f70(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsenonReimbursableTotal() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 22); - if (s1.toLowerCase() === peg$c78) { - peg$currPos += 22; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e82); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f71(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupFrom() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c79) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e83); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f72(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupExpenses() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c80) { - peg$currPos += 14; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e84); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f73(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupTotal() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c81) { - peg$currPos += 11; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e85); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f74(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupCard() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c82) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e86); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f75(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupFeed() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c83) { - peg$currPos += 10; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e87); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f76(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupBankAccount() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 18); - if (s1.toLowerCase() === peg$c84) { - peg$currPos += 18; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e88); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f77(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupWithdrawn() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c85) { - peg$currPos += 15; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e89); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f78(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsegroupWithdrawalId() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 19); - if (s1.toLowerCase() === peg$c86) { - peg$currPos += 19; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e90); } - } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f79(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s1 = peg$f45(); } + s0 = s1; return s0; } - function peg$parsegroupCategory() { - var s0, s1, s2, s3; + function peg$parsehas() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c87) { - peg$currPos += 14; + s1 = input.substr(peg$currPos, 3); + if (s1.toLowerCase() === peg$c53) { + peg$currPos += (3); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e91); } + if (peg$silentFails === 0) { peg$fail(peg$e56); } } if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f80(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + peg$savedPos = s0; + s1 = peg$f46(); } + s0 = s1; return s0; } - function peg$parsegroupTag() { - var s0, s1, s2, s3; + function peg$parseis() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c88) { - peg$currPos += 9; + s1 = input.substr(peg$currPos, 2); + if (s1.toLowerCase() === peg$c54) { + peg$currPos += (2); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e92); } + if (peg$silentFails === 0) { peg$fail(peg$e57); } } if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f81(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + peg$savedPos = s0; + s1 = peg$f47(); } + s0 = s1; return s0; } - function peg$parsegroupMerchant() { - var s0, s1, s2, s3; + function peg$parsepurchaseAmount() { + let s0, s1; - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c89) { - peg$currPos += 14; + s0 = input.substr(peg$currPos, 14); + if (s0.toLowerCase() === peg$c55) { + peg$currPos += (14); } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e93); } + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e58); } } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 15); + if (s1.toLowerCase() === peg$c56) { + peg$currPos += (15); } else { - s2 = peg$FAILED; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e59); } } - if (s2 !== peg$FAILED) { + if (s1 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f82(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s1 = peg$f48(); } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s0 = s1; } return s0; } - function peg$parsegroupMonth() { - var s0, s1, s2, s3; + function peg$parsepurchaseCurrency() { + let s0, s1; - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c90) { - peg$currPos += 11; + s0 = input.substr(peg$currPos, 16); + if (s0.toLowerCase() === peg$c57) { + peg$currPos += (16); } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e94); } + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e60); } } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 17); + if (s1.toLowerCase() === peg$c58) { + peg$currPos += (17); } else { - s2 = peg$FAILED; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e61); } } - if (s2 !== peg$FAILED) { + if (s1 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f83(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s1 = peg$f49(); } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + s0 = s1; } return s0; } - function peg$parsegroupWeek() { - var s0, s1, s2, s3; + function peg$parsecolumns() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c91) { - peg$currPos += 10; + s1 = input.substr(peg$currPos, 7); + if (s1.toLowerCase() === peg$c59) { + peg$currPos += (7); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e95); } + if (peg$silentFails === 0) { peg$fail(peg$e62); } } if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f84(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; + peg$savedPos = s0; + s1 = peg$f50(); } + s0 = s1; return s0; } - function peg$parsegroupYear() { - var s0, s1, s2, s3; + function peg$parseview() { + let s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c92) { - peg$currPos += 10; + s1 = input.substr(peg$currPos, 4); + if (s1.toLowerCase() === peg$c60) { + peg$currPos += (4); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e96); } + if (peg$silentFails === 0) { peg$fail(peg$e63); } } if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parsewordBoundary(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; + peg$savedPos = s0; + s1 = peg$f51(); + } + s0 = s1; + + return s0; + } + + function peg$parseexportedToKeyEnd() { + let s0, s1, s2; + + s0 = peg$parsewordBoundary(); + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = input.charAt(peg$currPos); + if (peg$r1.test(s2)) { + peg$currPos++; } else { s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e64); } } if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f85(); + s1 = [s1, s2]; + s0 = s1; } else { peg$currPos = s0; s0 = peg$FAILED; } - } else { - peg$currPos = s0; - s0 = peg$FAILED; } return s0; } - function peg$parsegroupQuarter() { - var s0, s1, s2, s3; + function peg$parseexportedTo() { + let s0, s1, s2, s3; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c93) { - peg$currPos += 13; + s1 = input.substr(peg$currPos, 10); + if (s1.toLowerCase() === peg$c61) { + peg$currPos += (10); } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e97); } + if (peg$silentFails === 0) { peg$fail(peg$e65); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; peg$silentFails++; - s3 = peg$parsewordBoundary(); + s3 = peg$parseexportedToKeyEnd(); peg$silentFails--; if (s3 !== peg$FAILED) { peg$currPos = s2; @@ -3605,7 +2196,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f86(); + s0 = peg$f52(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3614,97 +2205,144 @@ function peg$parse(input, options) { peg$currPos = s0; s0 = peg$FAILED; } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c62) { + peg$currPos += (11); + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e66); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parseexportedToKeyEnd(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f53(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } return s0; } function peg$parseoperator() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r1.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c63) { + s1 = peg$c63; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } + if (peg$silentFails === 0) { peg$fail(peg$e68); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f87(); + s1 = peg$f54(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c94) { - s1 = peg$c94; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r1.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e64); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f88(); + s1 = peg$f55(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c95) { - s1 = peg$c95; + if (input.substr(peg$currPos, 2) === peg$c64) { + s1 = peg$c64; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e100); } + if (peg$silentFails === 0) { peg$fail(peg$e69); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f89(); + s1 = peg$f56(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c96; - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c65) { + s1 = peg$c65; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e70); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f90(); + s1 = peg$f57(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c97) { - s1 = peg$c97; - peg$currPos += 2; + if (input.charCodeAt(peg$currPos) === 62) { + s1 = peg$c66; + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e102); } + if (peg$silentFails === 0) { peg$fail(peg$e71); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f91(); + s1 = peg$f58(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c98; - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c67) { + s1 = peg$c67; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e103); } + if (peg$silentFails === 0) { peg$fail(peg$e72); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f92(); + s1 = peg$f59(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c68; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e73); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f60(); + } + s0 = s1; + } } } } @@ -3713,20 +2351,20 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e98); } + if (peg$silentFails === 0) { peg$fail(peg$e67); } } return s0; } function peg$parsefilterOperator() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f93(s1); + s1 = peg$f61(s1); } s0 = s1; @@ -3734,7 +2372,7 @@ function peg$parse(input, options) { } function peg$parsealphanumeric() { - var s0, s1, s2; + let s0, s1, s2; peg$silentFails++; s0 = peg$currPos; @@ -3744,7 +2382,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -3754,7 +2392,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } } } else { @@ -3762,32 +2400,32 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f94(s1); + s1 = peg$f62(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e104); } + if (peg$silentFails === 0) { peg$fail(peg$e74); } } return s0; } function peg$parselogicalAnd() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f95(); + s1 = peg$f63(); s0 = s1; return s0; } function peg$parse_() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = []; @@ -3796,7 +2434,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e76); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -3805,22 +2443,20 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e76); } } } peg$silentFails--; - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } return s0; } function peg$parsequotedString() { - var s0, s1, s2; + let s0, s1, s2; s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f96(); + s1 = peg$f64(); if (s1) { s1 = undefined; } else { @@ -3847,7 +2483,7 @@ function peg$parse(input, options) { } function peg$parsesimpleQuotedString() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -3857,7 +2493,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3866,7 +2502,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } } s2 = input.charAt(peg$currPos); @@ -3874,7 +2510,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3883,7 +2519,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -3892,7 +2528,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } } s4 = input.charAt(peg$currPos); @@ -3900,7 +2536,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s4 !== peg$FAILED) { s5 = []; @@ -3909,7 +2545,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -3918,11 +2554,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e105); } + if (peg$silentFails === 0) { peg$fail(peg$e75); } } } peg$savedPos = s0; - s0 = peg$f97(s1, s3, s5); + s0 = peg$f65(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3934,14 +2570,14 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e77); } } return s0; } function peg$parsenestedQuotedString() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -3951,7 +2587,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -3960,7 +2596,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } } s2 = input.charAt(peg$currPos); @@ -3968,7 +2604,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s2 !== peg$FAILED) { s3 = []; @@ -3977,7 +2613,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -3993,15 +2629,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c99; + s6 = peg$c69; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e81); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f98(s1); + s4 = peg$f66(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4024,15 +2660,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c100; + s6 = peg$c70; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e82); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f99(s1); + s4 = peg$f67(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4055,15 +2691,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c101; + s6 = peg$c71; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e83); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f100(s1); + s4 = peg$f68(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4082,7 +2718,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4098,15 +2734,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c99; + s6 = peg$c69; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e81); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f98(s1); + s4 = peg$f66(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4129,15 +2765,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c100; + s6 = peg$c70; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e82); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f99(s1); + s4 = peg$f67(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4160,15 +2796,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c101; + s6 = peg$c71; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e83); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f100(s1); + s4 = peg$f68(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4184,7 +2820,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f101(s1, s3, s4); + s0 = peg$f69(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4196,14 +2832,14 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } + if (peg$silentFails === 0) { peg$fail(peg$e77); } } return s0; } function peg$parseclosingQuote() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.charAt(peg$currPos); @@ -4211,7 +2847,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e79); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4240,7 +2876,7 @@ function peg$parse(input, options) { } function peg$parsevalidClosingCondition() { - var s0, s1, s2, s3, s4; + let s0, s1, s2, s3, s4; s0 = peg$currPos; s1 = []; @@ -4249,7 +2885,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e84); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4258,7 +2894,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e84); } } } s2 = []; @@ -4267,7 +2903,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e116); } + if (peg$silentFails === 0) { peg$fail(peg$e85); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -4276,7 +2912,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e116); } + if (peg$silentFails === 0) { peg$fail(peg$e85); } } } s3 = []; @@ -4285,7 +2921,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e117); } + if (peg$silentFails === 0) { peg$fail(peg$e86); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -4294,7 +2930,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e117); } + if (peg$silentFails === 0) { peg$fail(peg$e86); } } } s4 = peg$parseoperator(); @@ -4313,7 +2949,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e118); } + if (peg$silentFails === 0) { peg$fail(peg$e87); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4322,7 +2958,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e118); } + if (peg$silentFails === 0) { peg$fail(peg$e87); } } } s2 = peg$currPos; @@ -4369,7 +3005,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e119); } + if (peg$silentFails === 0) { peg$fail(peg$e88); } } } } @@ -4378,14 +3014,14 @@ function peg$parse(input, options) { } function peg$parsewordBoundary() { - var s0, s1; + let s0, s1; s0 = input.charAt(peg$currPos); if (peg$r12.test(s0)) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e120); } + if (peg$silentFails === 0) { peg$fail(peg$e89); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -4424,30 +3060,36 @@ function peg$parse(input, options) { peg$result = peg$startRuleFunction(); - if (options.peg$library) { - return /** @type {any} */ ({ - peg$result, - peg$currPos, - peg$FAILED, - peg$maxFailExpected, - peg$maxFailPos - }); - } - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { + const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); + function peg$throw() { if (peg$result !== peg$FAILED && peg$currPos < input.length) { peg$fail(peg$endExpectation()); } throw peg$buildStructuredError( peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, peg$maxFailPos < input.length ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) ); } + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos, + peg$success, + peg$throw: peg$success ? undefined : peg$throw, + }); + } + if (peg$success) { + return peg$result; + } else { + peg$throw(); + } } const peg$allowedStartRules = [ diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index cc6f3e9fbfcb..ad60a1a0959d 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -157,7 +157,8 @@ groupQuarter = "group-quarter"i &wordBoundary { return "gr operator "operator" - = (":" / "=") { return "eq"; } + = "*:" { return "contains"; } + / (":" / "=") { return "eq"; } / "!=" { return "neq"; } / ">=" { return "gte"; } / ">" { return "gt"; } @@ -167,7 +168,7 @@ operator "operator" filterOperator = o:operator { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 897055c3bebd..f38d92cffd86 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -1,4 +1,4 @@ -// @generated by Peggy 4.0.3. +// @generated by Peggy 5.0.6. // // https://peggyjs.org/ @@ -12,624 +12,615 @@ return { operator, left, right }; } -function peg$subclass(child, parent) { - function C() { this.constructor = child; } - C.prototype = parent.prototype; - child.prototype = new C(); -} - -function peg$SyntaxError(message, expected, found, location) { - var self = Error.call(this, message); - // istanbul ignore next Check is a necessary evil to support older environments - if (Object.setPrototypeOf) { - Object.setPrototypeOf(self, peg$SyntaxError.prototype); - } - self.expected = expected; - self.found = found; - self.location = location; - self.name = "SyntaxError"; - return self; -} +class peg$SyntaxError extends SyntaxError { + constructor(message, expected, found, location) { + super(message); + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + } + + format(sources) { + let str = "Error: " + this.message; + if (this.location) { + let src = null; + const st = sources.find(s => s.source === this.location.source); + if (st) { + src = st.text.split(/\r\n|\n|\r/g); + } + const s = this.location.start; + const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + const e = this.location.end; + const filler = "".padEnd(offset_s.line.toString().length, " "); + const line = src[s.line - 1]; + const last = s.line === e.line ? e.column : line.length + 1; + const hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + "".padEnd(s.column - 1, " ") + + "".padEnd(hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; + } -peg$subclass(peg$SyntaxError, Error); + static buildMessage(expected, found) { + function hex(ch) { + return ch.codePointAt(0).toString(16).toUpperCase(); + } -function peg$padEnd(str, targetLength, padString) { - padString = padString || " "; - if (str.length > targetLength) { return str; } - targetLength -= str.length; - padString += padString.repeat(targetLength); - return str + padString.slice(0, targetLength); -} + const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") + ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") + : null; + function unicodeEscape(s) { + if (nonPrintable) { + return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); + } + return s; + } -peg$SyntaxError.prototype.format = function(sources) { - var str = "Error: " + this.message; - if (this.location) { - var src = null; - var k; - for (k = 0; k < sources.length; k++) { - if (sources[k].source === this.location.source) { - src = sources[k].text.split(/\r\n|\n|\r/g); - break; - } - } - var s = this.location.start; - var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) - ? this.location.source.offset(s) - : s; - var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; - if (src) { - var e = this.location.end; - var filler = peg$padEnd("", offset_s.line.toString().length, ' '); - var line = src[s.line - 1]; - var last = s.line === e.line ? e.column : line.length + 1; - var hatLen = (last - s.column) || 1; - str += "\n --> " + loc + "\n" - + filler + " |\n" - + offset_s.line + " | " + line + "\n" - + filler + " | " + peg$padEnd("", s.column - 1, ' ') - + peg$padEnd("", hatLen, "^"); - } else { - str += "\n at " + loc; - } - } - return str; -}; + function literalEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, + function classEscape(s) { + return unicodeEscape(s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) + .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); + } - class: function(expectation) { - var escapedParts = expectation.parts.map(function(part) { - return Array.isArray(part) - ? classEscape(part[0]) + "-" + classEscape(part[1]) - : classEscape(part); - }); + const DESCRIBE_EXPECTATION_FNS = { + literal(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, - return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; - }, + class(expectation) { + const escapedParts = expectation.parts.map( + part => (Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part)) + ); - any: function() { - return "any character"; - }, + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); + }, - end: function() { - return "end of input"; - }, + any() { + return "any character"; + }, - other: function(expectation) { - return expectation.description; - } - }; + end() { + return "end of input"; + }, - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } + other(expectation) { + return expectation.description; + }, + }; - function literalEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } - function classEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/\]/g, "\\]") - .replace(/\^/g, "\\^") - .replace(/-/g, "\\-") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } + function describeExpected(expected) { + const descriptions = expected.map(describeExpectation); + descriptions.sort(); - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } + if (descriptions.length > 0) { + let j = 1; + for (let i = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } - function describeExpected(expected) { - var descriptions = expected.map(describeExpectation); - var i, j; + switch (descriptions.length) { + case 1: + return descriptions[0]; - descriptions.sort(); + case 2: + return descriptions[0] + " or " + descriptions[1]; - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; } - descriptions.length = j; } - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; } - } - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; +} function peg$parse(input, options) { options = options !== undefined ? options : {}; - var peg$FAILED = {}; - var peg$source = options.grammarSource; - - var peg$startRuleFunctions = { query: peg$parsequery }; - var peg$startRuleFunction = peg$parsequery; - - var peg$c0 = "-"; - var peg$c1 = "report-field"; - var peg$c2 = "reportfield"; - var peg$c3 = ","; - var peg$c4 = "date"; - var peg$c5 = "amount"; - var peg$c6 = "merchant"; - var peg$c7 = "description"; - var peg$c8 = "reportid"; - var peg$c9 = "report-id"; - var peg$c10 = "keyword"; - var peg$c11 = "in"; - var peg$c12 = "currency"; - var peg$c13 = "groupCurrency"; - var peg$c14 = "group-currency"; - var peg$c15 = "tag"; - var peg$c16 = "category"; - var peg$c17 = "to"; - var peg$c18 = "exporter"; - var peg$c19 = "payer"; - var peg$c20 = "taxrate"; - var peg$c21 = "tax-rate"; - var peg$c22 = "cardID"; - var peg$c23 = "card"; - var peg$c24 = "from"; - var peg$c25 = "attendee"; - var peg$c26 = "expenseType"; - var peg$c27 = "expense-type"; - var peg$c28 = "withdrawalType"; - var peg$c29 = "withdrawal-type"; - var peg$c30 = "withdrawalid"; - var peg$c31 = "withdrawal-id"; - var peg$c32 = "billable"; - var peg$c33 = "reimbursable"; - var peg$c34 = "type"; - var peg$c35 = "status"; - var peg$c36 = "sortBy"; - var peg$c37 = "sort-by"; - var peg$c38 = "sortOrder"; - var peg$c39 = "sort-order"; - var peg$c40 = "policyID"; - var peg$c41 = "workspace"; - var peg$c42 = "submitted"; - var peg$c43 = "approved"; - var peg$c44 = "paid"; - var peg$c45 = "exported"; - var peg$c46 = "posted"; - var peg$c47 = "withdrawn"; - var peg$c48 = "groupby"; - var peg$c49 = "group-by"; - var peg$c50 = "limit"; - var peg$c51 = "feed"; - var peg$c52 = "title"; - var peg$c53 = "assignee"; - var peg$c54 = "createdby"; - var peg$c55 = "created-by"; - var peg$c56 = "action"; - var peg$c57 = "total"; - var peg$c58 = "has"; - var peg$c59 = "is"; - var peg$c60 = "purchaseamount"; - var peg$c61 = "purchase-amount"; - var peg$c62 = "purchasecurrency"; - var peg$c63 = "purchase-currency"; - var peg$c64 = "columns"; - var peg$c65 = "view"; - var peg$c66 = "per-diem"; - var peg$c67 = "drafts"; - var peg$c68 = "draft"; - var peg$c69 = "original-amount"; - var peg$c70 = "tax"; - var peg$c71 = "policy-name"; - var peg$c72 = "bank-account"; - var peg$c73 = "long-report-id"; - var peg$c74 = "exported-to"; - var peg$c75 = "exportedto"; - var peg$c76 = "exchange-rate"; - var peg$c77 = "reimbursable-total"; - var peg$c78 = "non-reimbursable-total"; - var peg$c79 = "group-from"; - var peg$c80 = "group-expenses"; - var peg$c81 = "group-total"; - var peg$c82 = "group-card"; - var peg$c83 = "group-feed"; - var peg$c84 = "group-bank-account"; - var peg$c85 = "group-withdrawn"; - var peg$c86 = "group-withdrawal-id"; - var peg$c87 = "group-category"; - var peg$c88 = "group-tag"; - var peg$c89 = "group-merchant"; - var peg$c90 = "group-month"; - var peg$c91 = "group-week"; - var peg$c92 = "group-year"; - var peg$c93 = "group-quarter"; - var peg$c94 = "!="; - var peg$c95 = ">="; - var peg$c96 = ">"; - var peg$c97 = "<="; - var peg$c98 = "<"; - var peg$c99 = "\u201C"; - var peg$c100 = "\u201D"; - var peg$c101 = "\""; - - var peg$r0 = /^[^ \t\r\n\xA0]/; - var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; - var peg$r2 = /^[:=]/; - var peg$r3 = /^[^ ,\t\n\r\xA0]/; - var peg$r4 = /^[ \t\r\n\xA0]/; - var peg$r5 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; - var peg$r6 = /^["\u201C-\u201D]/; - var peg$r7 = /^[^"\u201D\u201C\r\n]/; - var peg$r8 = /^[ \t\n\r\xA0a-zA-Z0-9]/; - var peg$r9 = /^[a-zA-Z0-9]/; - var peg$r10 = /^[ \t\n\r\xA0]/; - var peg$r11 = /^[ \t\n\r\xA0a-zA-Z]/; - var peg$r12 = /^[,]/; - var peg$r13 = /^[ \t\n\r\xA0,]/; - - var peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], true, false); - var peg$e1 = peg$literalExpectation("-", false); - var peg$e2 = peg$literalExpectation("report-field", true); - var peg$e3 = peg$literalExpectation("reportfield", true); - var peg$e4 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false); - var peg$e5 = peg$anyExpectation(); - var peg$e6 = peg$otherExpectation("key"); - var peg$e7 = peg$otherExpectation("default key"); - var peg$e8 = peg$literalExpectation(",", false); - var peg$e9 = peg$literalExpectation("date", true); - var peg$e10 = peg$literalExpectation("amount", true); - var peg$e11 = peg$literalExpectation("merchant", true); - var peg$e12 = peg$literalExpectation("description", true); - var peg$e13 = peg$literalExpectation("reportid", true); - var peg$e14 = peg$literalExpectation("report-id", true); - var peg$e15 = peg$literalExpectation("keyword", true); - var peg$e16 = peg$literalExpectation("in", true); - var peg$e17 = peg$literalExpectation("currency", true); - var peg$e18 = peg$literalExpectation("groupCurrency", false); - var peg$e19 = peg$literalExpectation("group-currency", true); - var peg$e20 = peg$literalExpectation("tag", true); - var peg$e21 = peg$literalExpectation("category", true); - var peg$e22 = peg$literalExpectation("to", true); - var peg$e23 = peg$literalExpectation("exporter", true); - var peg$e24 = peg$literalExpectation("payer", true); - var peg$e25 = peg$literalExpectation("taxRate", true); - var peg$e26 = peg$literalExpectation("tax-rate", true); - var peg$e27 = peg$literalExpectation("cardID", false); - var peg$e28 = peg$literalExpectation("card", true); - var peg$e29 = peg$literalExpectation("from", true); - var peg$e30 = peg$literalExpectation("attendee", true); - var peg$e31 = peg$literalExpectation("expenseType", false); - var peg$e32 = peg$literalExpectation("expense-type", true); - var peg$e33 = peg$literalExpectation("withdrawalType", false); - var peg$e34 = peg$literalExpectation("withdrawal-type", true); - var peg$e35 = peg$literalExpectation("withdrawalID", true); - var peg$e36 = peg$literalExpectation("withdrawal-id", true); - var peg$e37 = peg$literalExpectation("billable", true); - var peg$e38 = peg$literalExpectation("reimbursable", true); - var peg$e39 = peg$literalExpectation("type", true); - var peg$e40 = peg$literalExpectation("status", true); - var peg$e41 = peg$literalExpectation("sortBy", false); - var peg$e42 = peg$literalExpectation("sort-by", true); - var peg$e43 = peg$literalExpectation("sortOrder", false); - var peg$e44 = peg$literalExpectation("sort-order", true); - var peg$e45 = peg$literalExpectation("policyID", false); - var peg$e46 = peg$literalExpectation("workspace", true); - var peg$e47 = peg$literalExpectation("submitted", true); - var peg$e48 = peg$literalExpectation("approved", true); - var peg$e49 = peg$literalExpectation("paid", true); - var peg$e50 = peg$literalExpectation("exported", true); - var peg$e51 = peg$literalExpectation("posted", true); - var peg$e52 = peg$literalExpectation("withdrawn", true); - var peg$e53 = peg$literalExpectation("groupBy", true); - var peg$e54 = peg$literalExpectation("group-by", true); - var peg$e55 = peg$literalExpectation("limit", true); - var peg$e56 = peg$literalExpectation("feed", true); - var peg$e57 = peg$literalExpectation("title", true); - var peg$e58 = peg$literalExpectation("assignee", true); - var peg$e59 = peg$literalExpectation("createdBy", true); - var peg$e60 = peg$literalExpectation("created-by", true); - var peg$e61 = peg$literalExpectation("action", true); - var peg$e62 = peg$literalExpectation("total", true); - var peg$e63 = peg$literalExpectation("has", true); - var peg$e64 = peg$literalExpectation("is", true); - var peg$e65 = peg$literalExpectation("purchaseAmount", true); - var peg$e66 = peg$literalExpectation("purchase-amount", true); - var peg$e67 = peg$literalExpectation("purchaseCurrency", true); - var peg$e68 = peg$literalExpectation("purchase-currency", true); - var peg$e69 = peg$literalExpectation("columns", true); - var peg$e70 = peg$literalExpectation("view", true); - var peg$e71 = peg$literalExpectation("per-diem", true); - var peg$e72 = peg$literalExpectation("drafts", true); - var peg$e73 = peg$literalExpectation("draft", true); - var peg$e74 = peg$literalExpectation("original-amount", true); - var peg$e75 = peg$literalExpectation("tax", true); - var peg$e76 = peg$literalExpectation("policy-name", true); - var peg$e77 = peg$literalExpectation("bank-account", true); - var peg$e78 = peg$literalExpectation("long-report-id", true); - var peg$e79 = peg$literalExpectation("exported-to", true); - var peg$e80 = peg$classExpectation([":", "="], false, false); - var peg$e81 = peg$literalExpectation("exportedTo", true); - var peg$e82 = peg$literalExpectation("exchange-rate", true); - var peg$e83 = peg$literalExpectation("reimbursable-total", true); - var peg$e84 = peg$literalExpectation("non-reimbursable-total", true); - var peg$e85 = peg$literalExpectation("group-from", true); - var peg$e86 = peg$literalExpectation("group-expenses", true); - var peg$e87 = peg$literalExpectation("group-total", true); - var peg$e88 = peg$literalExpectation("group-card", true); - var peg$e89 = peg$literalExpectation("group-feed", true); - var peg$e90 = peg$literalExpectation("group-bank-account", true); - var peg$e91 = peg$literalExpectation("group-withdrawn", true); - var peg$e92 = peg$literalExpectation("group-withdrawal-id", true); - var peg$e93 = peg$literalExpectation("group-category", true); - var peg$e94 = peg$literalExpectation("group-tag", true); - var peg$e95 = peg$literalExpectation("group-merchant", true); - var peg$e96 = peg$literalExpectation("group-month", true); - var peg$e97 = peg$literalExpectation("group-week", true); - var peg$e98 = peg$literalExpectation("group-year", true); - var peg$e99 = peg$literalExpectation("group-quarter", true); - var peg$e100 = peg$otherExpectation("operator"); - var peg$e101 = peg$literalExpectation("!=", false); - var peg$e102 = peg$literalExpectation(">=", false); - var peg$e103 = peg$literalExpectation(">", false); - var peg$e104 = peg$literalExpectation("<=", false); - var peg$e105 = peg$literalExpectation("<", false); - var peg$e106 = peg$otherExpectation("word"); - var peg$e107 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e108 = peg$otherExpectation("whitespace"); - var peg$e109 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e110 = peg$otherExpectation("quote"); - var peg$e111 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e112 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e113 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e114 = peg$literalExpectation("\u201C", false); - var peg$e115 = peg$literalExpectation("\u201D", false); - var peg$e116 = peg$literalExpectation("\"", false); - var peg$e117 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e118 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e119 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e120 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e121 = peg$classExpectation([","], false, false); - var peg$e122 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - - var peg$f0 = function(filters) { return applyDefaults(filters); }; - var peg$f1 = function(head, tail) { - const allFilters = [head, ...tail.map(([_, filter]) => filter)] - .filter(Boolean) - .filter((filter) => filter.right); - rawFilterList = allFilters.map(createRawFilter).filter(Boolean); - - const filtersWithoutDefaults = allFilters.filter((filter) => !filter.isDefault); - - if (!filtersWithoutDefaults.length) { - return null; - } - - const keywords = filtersWithoutDefaults.filter( - (filter) => - filter.left === "keyword" || filter.right?.left === "keyword" - ); - const nonKeywords = filtersWithoutDefaults.filter( - (filter) => - filter.left !== "keyword" && filter.right?.left !== "keyword" - ); - - const keywordFilter = buildFilter( - "eq", - "keyword", - keywords - .map((filter) => filter.right.replaceAll(/^(['"])(.*)\1$/g, "$2")) - .flat() - ); - if (keywordFilter.right.length > 0) { - nonKeywords.push(keywordFilter); - } - return nonKeywords.reduce((result, filter) => - buildFilter("and", result, filter) - ); - }; - var peg$f2 = function(key, op, value) { - updateDefaultValues(key, value); - return {...buildFilter(op, key, value), isDefault: true}; - }; - var peg$f3 = function(value) { - //handle no-breaking space - let word; - if (Array.isArray(value)) { - word = value.join(""); - } else { - word = value; - } - return buildFilter("eq", "keyword", word); - }; - var peg$f4 = function(neg, field, op, values) { - expectingNestedQuote = false; nameOperator = false; + const peg$FAILED = {}; + const peg$source = options.grammarSource; - const key = field; - let operator = op; + const peg$startRuleFunctions = { + query: peg$parsequery, + }; + let peg$startRuleFunction = peg$parsequery; + + const peg$c0 = "-"; + const peg$c1 = "report-field"; + const peg$c2 = "reportfield"; + const peg$c3 = ","; + const peg$c4 = "date"; + const peg$c5 = "amount"; + const peg$c6 = "merchant"; + const peg$c7 = "description"; + const peg$c8 = "reportid"; + const peg$c9 = "report-id"; + const peg$c10 = "keyword"; + const peg$c11 = "in"; + const peg$c12 = "currency"; + const peg$c13 = "groupCurrency"; + const peg$c14 = "group-currency"; + const peg$c15 = "tag"; + const peg$c16 = "category"; + const peg$c17 = "to"; + const peg$c18 = "exporter"; + const peg$c19 = "payer"; + const peg$c20 = "taxrate"; + const peg$c21 = "tax-rate"; + const peg$c22 = "cardID"; + const peg$c23 = "card"; + const peg$c24 = "from"; + const peg$c25 = "attendee"; + const peg$c26 = "expenseType"; + const peg$c27 = "expense-type"; + const peg$c28 = "withdrawalType"; + const peg$c29 = "withdrawal-type"; + const peg$c30 = "withdrawalid"; + const peg$c31 = "withdrawal-id"; + const peg$c32 = "billable"; + const peg$c33 = "reimbursable"; + const peg$c34 = "type"; + const peg$c35 = "status"; + const peg$c36 = "sortBy"; + const peg$c37 = "sort-by"; + const peg$c38 = "sortOrder"; + const peg$c39 = "sort-order"; + const peg$c40 = "policyID"; + const peg$c41 = "workspace"; + const peg$c42 = "submitted"; + const peg$c43 = "approved"; + const peg$c44 = "paid"; + const peg$c45 = "exported"; + const peg$c46 = "posted"; + const peg$c47 = "withdrawn"; + const peg$c48 = "groupby"; + const peg$c49 = "group-by"; + const peg$c50 = "limit"; + const peg$c51 = "feed"; + const peg$c52 = "title"; + const peg$c53 = "assignee"; + const peg$c54 = "createdby"; + const peg$c55 = "created-by"; + const peg$c56 = "action"; + const peg$c57 = "total"; + const peg$c58 = "has"; + const peg$c59 = "is"; + const peg$c60 = "purchaseamount"; + const peg$c61 = "purchase-amount"; + const peg$c62 = "purchasecurrency"; + const peg$c63 = "purchase-currency"; + const peg$c64 = "columns"; + const peg$c65 = "view"; + const peg$c66 = "per-diem"; + const peg$c67 = "drafts"; + const peg$c68 = "draft"; + const peg$c69 = "original-amount"; + const peg$c70 = "tax"; + const peg$c71 = "policy-name"; + const peg$c72 = "bank-account"; + const peg$c73 = "long-report-id"; + const peg$c74 = "exported-to"; + const peg$c75 = "exportedto"; + const peg$c76 = "exchange-rate"; + const peg$c77 = "reimbursable-total"; + const peg$c78 = "non-reimbursable-total"; + const peg$c79 = "group-from"; + const peg$c80 = "group-expenses"; + const peg$c81 = "group-total"; + const peg$c82 = "group-card"; + const peg$c83 = "group-feed"; + const peg$c84 = "group-bank-account"; + const peg$c85 = "group-withdrawn"; + const peg$c86 = "group-withdrawal-id"; + const peg$c87 = "group-category"; + const peg$c88 = "group-tag"; + const peg$c89 = "group-merchant"; + const peg$c90 = "group-month"; + const peg$c91 = "group-week"; + const peg$c92 = "group-year"; + const peg$c93 = "group-quarter"; + const peg$c94 = "*:"; + const peg$c95 = "!="; + const peg$c96 = ">="; + const peg$c97 = ">"; + const peg$c98 = "<="; + const peg$c99 = "<"; + const peg$c100 = "\u201C"; + const peg$c101 = "\u201D"; + const peg$c102 = "\""; + + const peg$r0 = /^[^ \t\r\n\xA0]/; + const peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; + const peg$r2 = /^[:=]/; + const peg$r3 = /^[^ ,\t\n\r\xA0]/; + const peg$r4 = /^[ \t\r\n\xA0]/; + const peg$r5 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; + const peg$r6 = /^["\u201C-\u201D]/; + const peg$r7 = /^[^"\u201D\u201C\r\n]/; + const peg$r8 = /^[ \t\n\r\xA0a-zA-Z0-9]/; + const peg$r9 = /^[a-zA-Z0-9]/; + const peg$r10 = /^[ \t\n\r\xA0]/; + const peg$r11 = /^[ \t\n\r\xA0a-zA-Z]/; + const peg$r12 = /^[,]/; + const peg$r13 = /^[ \t\n\r\xA0,]/; + + const peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], true, false, false); + const peg$e1 = peg$literalExpectation("-", false); + const peg$e2 = peg$literalExpectation("report-field", true); + const peg$e3 = peg$literalExpectation("reportfield", true); + const peg$e4 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false, false); + const peg$e5 = peg$anyExpectation(); + const peg$e6 = peg$otherExpectation("key"); + const peg$e7 = peg$otherExpectation("default key"); + const peg$e8 = peg$literalExpectation(",", false); + const peg$e9 = peg$literalExpectation("date", true); + const peg$e10 = peg$literalExpectation("amount", true); + const peg$e11 = peg$literalExpectation("merchant", true); + const peg$e12 = peg$literalExpectation("description", true); + const peg$e13 = peg$literalExpectation("reportid", true); + const peg$e14 = peg$literalExpectation("report-id", true); + const peg$e15 = peg$literalExpectation("keyword", true); + const peg$e16 = peg$literalExpectation("in", true); + const peg$e17 = peg$literalExpectation("currency", true); + const peg$e18 = peg$literalExpectation("groupCurrency", false); + const peg$e19 = peg$literalExpectation("group-currency", true); + const peg$e20 = peg$literalExpectation("tag", true); + const peg$e21 = peg$literalExpectation("category", true); + const peg$e22 = peg$literalExpectation("to", true); + const peg$e23 = peg$literalExpectation("exporter", true); + const peg$e24 = peg$literalExpectation("payer", true); + const peg$e25 = peg$literalExpectation("taxRate", true); + const peg$e26 = peg$literalExpectation("tax-rate", true); + const peg$e27 = peg$literalExpectation("cardID", false); + const peg$e28 = peg$literalExpectation("card", true); + const peg$e29 = peg$literalExpectation("from", true); + const peg$e30 = peg$literalExpectation("attendee", true); + const peg$e31 = peg$literalExpectation("expenseType", false); + const peg$e32 = peg$literalExpectation("expense-type", true); + const peg$e33 = peg$literalExpectation("withdrawalType", false); + const peg$e34 = peg$literalExpectation("withdrawal-type", true); + const peg$e35 = peg$literalExpectation("withdrawalID", true); + const peg$e36 = peg$literalExpectation("withdrawal-id", true); + const peg$e37 = peg$literalExpectation("billable", true); + const peg$e38 = peg$literalExpectation("reimbursable", true); + const peg$e39 = peg$literalExpectation("type", true); + const peg$e40 = peg$literalExpectation("status", true); + const peg$e41 = peg$literalExpectation("sortBy", false); + const peg$e42 = peg$literalExpectation("sort-by", true); + const peg$e43 = peg$literalExpectation("sortOrder", false); + const peg$e44 = peg$literalExpectation("sort-order", true); + const peg$e45 = peg$literalExpectation("policyID", false); + const peg$e46 = peg$literalExpectation("workspace", true); + const peg$e47 = peg$literalExpectation("submitted", true); + const peg$e48 = peg$literalExpectation("approved", true); + const peg$e49 = peg$literalExpectation("paid", true); + const peg$e50 = peg$literalExpectation("exported", true); + const peg$e51 = peg$literalExpectation("posted", true); + const peg$e52 = peg$literalExpectation("withdrawn", true); + const peg$e53 = peg$literalExpectation("groupBy", true); + const peg$e54 = peg$literalExpectation("group-by", true); + const peg$e55 = peg$literalExpectation("limit", true); + const peg$e56 = peg$literalExpectation("feed", true); + const peg$e57 = peg$literalExpectation("title", true); + const peg$e58 = peg$literalExpectation("assignee", true); + const peg$e59 = peg$literalExpectation("createdBy", true); + const peg$e60 = peg$literalExpectation("created-by", true); + const peg$e61 = peg$literalExpectation("action", true); + const peg$e62 = peg$literalExpectation("total", true); + const peg$e63 = peg$literalExpectation("has", true); + const peg$e64 = peg$literalExpectation("is", true); + const peg$e65 = peg$literalExpectation("purchaseAmount", true); + const peg$e66 = peg$literalExpectation("purchase-amount", true); + const peg$e67 = peg$literalExpectation("purchaseCurrency", true); + const peg$e68 = peg$literalExpectation("purchase-currency", true); + const peg$e69 = peg$literalExpectation("columns", true); + const peg$e70 = peg$literalExpectation("view", true); + const peg$e71 = peg$literalExpectation("per-diem", true); + const peg$e72 = peg$literalExpectation("drafts", true); + const peg$e73 = peg$literalExpectation("draft", true); + const peg$e74 = peg$literalExpectation("original-amount", true); + const peg$e75 = peg$literalExpectation("tax", true); + const peg$e76 = peg$literalExpectation("policy-name", true); + const peg$e77 = peg$literalExpectation("bank-account", true); + const peg$e78 = peg$literalExpectation("long-report-id", true); + const peg$e79 = peg$literalExpectation("exported-to", true); + const peg$e80 = peg$classExpectation([":", "="], false, false, false); + const peg$e81 = peg$literalExpectation("exportedTo", true); + const peg$e82 = peg$literalExpectation("exchange-rate", true); + const peg$e83 = peg$literalExpectation("reimbursable-total", true); + const peg$e84 = peg$literalExpectation("non-reimbursable-total", true); + const peg$e85 = peg$literalExpectation("group-from", true); + const peg$e86 = peg$literalExpectation("group-expenses", true); + const peg$e87 = peg$literalExpectation("group-total", true); + const peg$e88 = peg$literalExpectation("group-card", true); + const peg$e89 = peg$literalExpectation("group-feed", true); + const peg$e90 = peg$literalExpectation("group-bank-account", true); + const peg$e91 = peg$literalExpectation("group-withdrawn", true); + const peg$e92 = peg$literalExpectation("group-withdrawal-id", true); + const peg$e93 = peg$literalExpectation("group-category", true); + const peg$e94 = peg$literalExpectation("group-tag", true); + const peg$e95 = peg$literalExpectation("group-merchant", true); + const peg$e96 = peg$literalExpectation("group-month", true); + const peg$e97 = peg$literalExpectation("group-week", true); + const peg$e98 = peg$literalExpectation("group-year", true); + const peg$e99 = peg$literalExpectation("group-quarter", true); + const peg$e100 = peg$otherExpectation("operator"); + const peg$e101 = peg$literalExpectation("*:", false); + const peg$e102 = peg$literalExpectation("!=", false); + const peg$e103 = peg$literalExpectation(">=", false); + const peg$e104 = peg$literalExpectation(">", false); + const peg$e105 = peg$literalExpectation("<=", false); + const peg$e106 = peg$literalExpectation("<", false); + const peg$e107 = peg$otherExpectation("word"); + const peg$e108 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false, false); + const peg$e109 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false, false); + const peg$e110 = peg$otherExpectation("quote"); + const peg$e111 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false, false); + const peg$e112 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false, false); + const peg$e113 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false, false); + const peg$e114 = peg$literalExpectation("\u201C", false); + const peg$e115 = peg$literalExpectation("\u201D", false); + const peg$e116 = peg$literalExpectation("\"", false); + const peg$e117 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); + const peg$e118 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); + const peg$e119 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false, false); + const peg$e120 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false, false); + const peg$e121 = peg$classExpectation([","], false, false, false); + const peg$e122 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false, false); + + function peg$f0(filters) { return applyDefaults(filters); } + function peg$f1(head, tail) { + const allFilters = [head, ...tail.map(([_, filter]) => filter)] + .filter(Boolean) + .filter((filter) => filter.right); + rawFilterList = allFilters.map(createRawFilter).filter(Boolean); + + const filtersWithoutDefaults = allFilters.filter((filter) => !filter.isDefault); + + if (!filtersWithoutDefaults.length) { + return null; + } - // Apply negation only when "-" is present and the key can be negated - if (neg && !nonNegatableKeys.has(key)) { - if (operator === "eq") { - operator = "neq"; - } + const keywords = filtersWithoutDefaults.filter( + (filter) => + filter.left === "keyword" || filter.right?.left === "keyword" + ); + const nonKeywords = filtersWithoutDefaults.filter( + (filter) => + filter.left !== "keyword" && filter.right?.left !== "keyword" + ); + + const keywordFilter = buildFilter( + "eq", + "keyword", + keywords + .map((filter) => filter.right.replaceAll(/^(['"])(.*)\1$/g, "$2")) + .flat() + ); + if (keywordFilter.right.length > 0) { + nonKeywords.push(keywordFilter); + } + return nonKeywords.reduce((result, filter) => + buildFilter("and", result, filter) + ); + } + function peg$f2(key, op, value) { + updateDefaultValues(key, value); + return {...buildFilter(op, key, value), isDefault: true}; + } + function peg$f3(value) { + //handle no-breaking space + let word; + if (Array.isArray(value)) { + word = value.join(""); + } else { + word = value; + } + return buildFilter("eq", "keyword", word); + } + function peg$f4(neg, field, op, values) { + expectingNestedQuote = false; nameOperator = false; + + const key = field; + let operator = op; + + // Apply negation only when "-" is present and the key can be negated + if (neg && !nonNegatableKeys.has(key)) { + if (operator === "eq") { + operator = "neq"; } + } - return buildFilter(operator, key, values); - }; - var peg$f5 = function(rest) { - const suffix = rest.replaceAll(/^-+/g, ""); - return "reportField-" + (suffix ? suffix : ""); - }; - var peg$f6 = function(k) { - nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); - return k; - }; - var peg$f7 = function(parts, empty) { - const value = parts.flat().filter(Boolean).map((word) => { - if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { - return word.slice(1, -1); - } - return word; - }); - if (value.length > 1) { - return value.filter((word) => word.length > 0); + return buildFilter(operator, key, values); + } + function peg$f5(rest) { + const suffix = rest.replaceAll(/^-+/g, ""); + return "reportField-" + (suffix ? suffix : ""); + } + function peg$f6(k) { + nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); + return k; + } + function peg$f7(parts, empty) { + const value = parts.flat().filter(Boolean).map((word) => { + if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { + return word.slice(1, -1); } - return value[0]; - }; - var peg$f8 = function() { return "date"; }; - var peg$f9 = function() { return "amount"; }; - var peg$f10 = function() { return "merchant"; }; - var peg$f11 = function() { return "description"; }; - var peg$f12 = function() { return "reportID"; }; - var peg$f13 = function() { return "keyword"; }; - var peg$f14 = function() { return "in"; }; - var peg$f15 = function() { return "currency"; }; - var peg$f16 = function() { return "groupCurrency"; }; - var peg$f17 = function() { return "tag"; }; - var peg$f18 = function() { return "category"; }; - var peg$f19 = function() { return "to"; }; - var peg$f20 = function() { return "exporter"; }; - var peg$f21 = function() { return "payer"; }; - var peg$f22 = function() { return "taxRate"; }; - var peg$f23 = function() { return "cardID"; }; - var peg$f24 = function() { return "from"; }; - var peg$f25 = function() {return "attendee"}; - var peg$f26 = function() { return "expenseType"; }; - var peg$f27 = function() { return "withdrawalType"; }; - var peg$f28 = function() { return "withdrawalID"; }; - var peg$f29 = function() { return "billable"; }; - var peg$f30 = function() { return "reimbursable"; }; - var peg$f31 = function() { return "type"; }; - var peg$f32 = function() { return "status"; }; - var peg$f33 = function() { return "sortBy"; }; - var peg$f34 = function() { return "sortOrder"; }; - var peg$f35 = function() { return "policyID"; }; - var peg$f36 = function() { return "submitted"; }; - var peg$f37 = function() { return "approved"; }; - var peg$f38 = function() { return "paid"; }; - var peg$f39 = function() { return "exported"; }; - var peg$f40 = function() { return "posted"; }; - var peg$f41 = function() { return "withdrawn"; }; - var peg$f42 = function() { return "groupBy"; }; - var peg$f43 = function() { return "limit"; }; - var peg$f44 = function() { return "feed"; }; - var peg$f45 = function() { return "title"; }; - var peg$f46 = function() { return "assignee"; }; - var peg$f47 = function() { return "createdBy"; }; - var peg$f48 = function() { return "action"; }; - var peg$f49 = function() {return "total"; }; - var peg$f50 = function() {return "has"; }; - var peg$f51 = function() {return "is"; }; - var peg$f52 = function() {return "purchaseAmount"}; - var peg$f53 = function() {return "purchaseCurrency"}; - var peg$f54 = function() { - isColumnsContext = true; - return "columns"; -}; - var peg$f55 = function() {return "view"}; - var peg$f56 = function() { return isColumnsContext; }; - var peg$f57 = function() { return "perDiem"; }; - var peg$f58 = function() { return "drafts"; }; - var peg$f59 = function() { return "originalamount"; }; - var peg$f60 = function() { return "taxAmount"; }; - var peg$f61 = function() { return "taxrate"; }; - var peg$f62 = function() { return "policyname"; }; - var peg$f63 = function() { return "withdrawalID"; }; - var peg$f64 = function() { return "bankAccount"; }; - var peg$f65 = function() { return "reportID"; }; - var peg$f66 = function() { return "base62ReportID"; }; - var peg$f67 = function() { return "exportedto"; }; - var peg$f68 = function() { return "exportedTo"; }; - var peg$f69 = function() { return "exportedTo"; }; - var peg$f70 = function() { return "exchangeRate"; }; - var peg$f71 = function() { return "reimbursableTotal"; }; - var peg$f72 = function() { return "nonReimbursableTotal"; }; - var peg$f73 = function() { return "groupFrom"; }; - var peg$f74 = function() { return "groupExpenses"; }; - var peg$f75 = function() { return "groupTotal"; }; - var peg$f76 = function() { return "groupCard"; }; - var peg$f77 = function() { return "groupFeed"; }; - var peg$f78 = function() { return "groupBankAccount"; }; - var peg$f79 = function() { return "groupWithdrawn"; }; - var peg$f80 = function() { return "groupWithdrawalID"; }; - var peg$f81 = function() { return "groupCategory"; }; - var peg$f82 = function() { return "groupTag"; }; - var peg$f83 = function() { return "groupMerchant"; }; - var peg$f84 = function() { return "groupmonth"; }; - var peg$f85 = function() { return "groupweek"; }; - var peg$f86 = function() { return "groupyear"; }; - var peg$f87 = function() { return "groupquarter"; }; - var peg$f88 = function() { return "eq"; }; - var peg$f89 = function() { return "neq"; }; - var peg$f90 = function() { return "gte"; }; - var peg$f91 = function() { return "gt"; }; - var peg$f92 = function() { return "lte"; }; - var peg$f93 = function() { return "lt"; }; - var peg$f94 = function(o) { - if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found - } - isColumnsContext = false; - return o; - }; - var peg$f95 = function(chars) { return chars.join("").trim(); }; - var peg$f96 = function() { - isColumnsContext = false; - return "and"; -}; - var peg$f97 = function() { return expectingNestedQuote; }; - var peg$f98 = function(start, inner, end) { //handle no-breaking space - return [...start, '"', ...inner, '"', ...end].join(""); - }; - var peg$f99 = function(start) {return "“"}; - var peg$f100 = function(start) {return "”"}; - var peg$f101 = function(start) {return "\""}; - var peg$f102 = function(start, inner, end) { + return word; + }); + if (value.length > 1) { + return value.filter((word) => word.length > 0); + } + return value[0]; + } + function peg$f8() { return "date"; } + function peg$f9() { return "amount"; } + function peg$f10() { return "merchant"; } + function peg$f11() { return "description"; } + function peg$f12() { return "reportID"; } + function peg$f13() { return "keyword"; } + function peg$f14() { return "in"; } + function peg$f15() { return "currency"; } + function peg$f16() { return "groupCurrency"; } + function peg$f17() { return "tag"; } + function peg$f18() { return "category"; } + function peg$f19() { return "to"; } + function peg$f20() { return "exporter"; } + function peg$f21() { return "payer"; } + function peg$f22() { return "taxRate"; } + function peg$f23() { return "cardID"; } + function peg$f24() { return "from"; } + function peg$f25() {return "attendee" } + function peg$f26() { return "expenseType"; } + function peg$f27() { return "withdrawalType"; } + function peg$f28() { return "withdrawalID"; } + function peg$f29() { return "billable"; } + function peg$f30() { return "reimbursable"; } + function peg$f31() { return "type"; } + function peg$f32() { return "status"; } + function peg$f33() { return "sortBy"; } + function peg$f34() { return "sortOrder"; } + function peg$f35() { return "policyID"; } + function peg$f36() { return "submitted"; } + function peg$f37() { return "approved"; } + function peg$f38() { return "paid"; } + function peg$f39() { return "exported"; } + function peg$f40() { return "posted"; } + function peg$f41() { return "withdrawn"; } + function peg$f42() { return "groupBy"; } + function peg$f43() { return "limit"; } + function peg$f44() { return "feed"; } + function peg$f45() { return "title"; } + function peg$f46() { return "assignee"; } + function peg$f47() { return "createdBy"; } + function peg$f48() { return "action"; } + function peg$f49() {return "total"; } + function peg$f50() {return "has"; } + function peg$f51() {return "is"; } + function peg$f52() {return "purchaseAmount" } + function peg$f53() {return "purchaseCurrency" } + function peg$f54() { + isColumnsContext = true; + return "columns"; + } + function peg$f55() {return "view" } + function peg$f56() { return isColumnsContext; } + function peg$f57() { return "perDiem"; } + function peg$f58() { return "drafts"; } + function peg$f59() { return "originalamount"; } + function peg$f60() { return "taxAmount"; } + function peg$f61() { return "taxrate"; } + function peg$f62() { return "policyname"; } + function peg$f63() { return "withdrawalID"; } + function peg$f64() { return "bankAccount"; } + function peg$f65() { return "reportID"; } + function peg$f66() { return "base62ReportID"; } + function peg$f67() { return "exportedto"; } + function peg$f68() { return "exportedTo"; } + function peg$f69() { return "exportedTo"; } + function peg$f70() { return "exchangeRate"; } + function peg$f71() { return "reimbursableTotal"; } + function peg$f72() { return "nonReimbursableTotal"; } + function peg$f73() { return "groupFrom"; } + function peg$f74() { return "groupExpenses"; } + function peg$f75() { return "groupTotal"; } + function peg$f76() { return "groupCard"; } + function peg$f77() { return "groupFeed"; } + function peg$f78() { return "groupBankAccount"; } + function peg$f79() { return "groupWithdrawn"; } + function peg$f80() { return "groupWithdrawalID"; } + function peg$f81() { return "groupCategory"; } + function peg$f82() { return "groupTag"; } + function peg$f83() { return "groupMerchant"; } + function peg$f84() { return "groupmonth"; } + function peg$f85() { return "groupweek"; } + function peg$f86() { return "groupyear"; } + function peg$f87() { return "groupquarter"; } + function peg$f88() { return "contains"; } + function peg$f89() { return "eq"; } + function peg$f90() { return "neq"; } + function peg$f91() { return "gte"; } + function peg$f92() { return "gt"; } + function peg$f93() { return "lte"; } + function peg$f94() { return "lt"; } + function peg$f95(o) { + if (nameOperator) { + expectingNestedQuote = (o === "eq" || o === "contains"); + } + isColumnsContext = false; + return o; + } + function peg$f96(chars) { return chars.join("").trim(); } + function peg$f97() { + isColumnsContext = false; + return "and"; + } + function peg$f98() { return expectingNestedQuote; } + function peg$f99(start, inner, end) { //handle no-breaking space + return [...start, '"', ...inner, '"', ...end].join(""); + } + function peg$f100(start) {return "“" } + function peg$f101(start) {return "”" } + function peg$f102(start) {return "\"" } + function peg$f103(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); -}; - var peg$currPos = options.peg$currPos | 0; - var peg$savedPos = peg$currPos; - var peg$posDetailsCache = [{ line: 1, column: 1 }]; - var peg$maxFailPos = peg$currPos; - var peg$maxFailExpected = options.peg$maxFailExpected || []; - var peg$silentFails = options.peg$silentFails | 0; + } + let peg$currPos = options.peg$currPos | 0; + let peg$savedPos = peg$currPos; + const peg$posDetailsCache = [{ line: 1, column: 1 }]; + let peg$maxFailPos = peg$currPos; + let peg$maxFailExpected = options.peg$maxFailExpected || []; + let peg$silentFails = options.peg$silentFails | 0; - var peg$result; + let peg$result; if (options.startRule) { if (!(options.startRule in peg$startRuleFunctions)) { @@ -651,7 +642,7 @@ function peg$parse(input, options) { return { source: peg$source, start: peg$savedPos, - end: peg$currPos + end: peg$currPos, }; } @@ -679,12 +670,20 @@ function peg$parse(input, options) { throw peg$buildSimpleError(message, location); } + function peg$getUnicode(pos = peg$currPos) { + const cp = input.codePointAt(pos); + if (cp === undefined) { + return ""; + } + return String.fromCodePoint(cp); + } + function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; + return { type: "literal", text, ignoreCase }; } - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + function peg$classExpectation(parts, inverted, ignoreCase, unicode) { + return { type: "class", parts, inverted, ignoreCase, unicode }; } function peg$anyExpectation() { @@ -696,12 +695,12 @@ function peg$parse(input, options) { } function peg$otherExpectation(description) { - return { type: "other", description: description }; + return { type: "other", description }; } function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos]; - var p; + let details = peg$posDetailsCache[pos]; + let p; if (details) { return details; @@ -716,7 +715,7 @@ function peg$parse(input, options) { details = peg$posDetailsCache[p]; details = { line: details.line, - column: details.column + column: details.column, }; while (p < pos) { @@ -737,21 +736,21 @@ function peg$parse(input, options) { } function peg$computeLocation(startPos, endPos, offset) { - var startPosDetails = peg$computePosDetails(startPos); - var endPosDetails = peg$computePosDetails(endPos); + const startPosDetails = peg$computePosDetails(startPos); + const endPosDetails = peg$computePosDetails(endPos); - var res = { + const res = { source: peg$source, start: { offset: startPos, line: startPosDetails.line, - column: startPosDetails.column + column: startPosDetails.column, }, end: { offset: endPos, line: endPosDetails.line, - column: endPosDetails.column - } + column: endPosDetails.column, + }, }; if (offset && peg$source && (typeof peg$source.offset === "function")) { res.start = peg$source.offset(res.start); @@ -785,7 +784,7 @@ function peg$parse(input, options) { } function peg$parsequery() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -801,7 +800,7 @@ function peg$parse(input, options) { } function peg$parsefilterList() { - var s0, s1, s2, s3, s4, s5; + let s0, s1, s2, s3, s4, s5; s0 = peg$currPos; s1 = peg$parsefilter(); @@ -841,7 +840,7 @@ function peg$parse(input, options) { } function peg$parsefilter() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parsestandardFilter(); @@ -862,7 +861,7 @@ function peg$parse(input, options) { } function peg$parsedefaultFilter() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; s0 = peg$currPos; s1 = peg$parse_(); @@ -893,7 +892,7 @@ function peg$parse(input, options) { } function peg$parsefreeTextFilter() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -935,7 +934,7 @@ function peg$parse(input, options) { } function peg$parsestandardFilter() { - var s0, s1, s2, s3, s4, s5, s6, s7; + let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = peg$parse_(); @@ -976,12 +975,12 @@ function peg$parse(input, options) { } function peg$parsereportFieldDynamic() { - var s0, s1, s2, s3, s4, s5, s6, s7; + let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c1) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e2); } @@ -989,7 +988,7 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c2) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e3); } @@ -1097,7 +1096,7 @@ function peg$parse(input, options) { } function peg$parsekey() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -1238,7 +1237,7 @@ function peg$parse(input, options) { } function peg$parsefilterKey() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parsekey(); @@ -1252,7 +1251,7 @@ function peg$parse(input, options) { } function peg$parsedefaultKey() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -1297,7 +1296,7 @@ function peg$parse(input, options) { } function peg$parseidentifier() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; s0 = peg$currPos; s1 = []; @@ -1420,12 +1419,12 @@ function peg$parse(input, options) { } function peg$parsedate() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c4) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e9); } @@ -1440,12 +1439,12 @@ function peg$parse(input, options) { } function peg$parseamount() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c5) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e10); } @@ -1460,12 +1459,12 @@ function peg$parse(input, options) { } function peg$parsemerchant() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c6) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e11); } @@ -1480,12 +1479,12 @@ function peg$parse(input, options) { } function peg$parsedescription() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c7) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e12); } @@ -1500,11 +1499,11 @@ function peg$parse(input, options) { } function peg$parsereportID() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 8); if (s0.toLowerCase() === peg$c8) { - peg$currPos += 8; + peg$currPos += (8); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e13); } @@ -1513,7 +1512,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c9) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e14); } @@ -1529,12 +1528,12 @@ function peg$parse(input, options) { } function peg$parsekeyword() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c10) { - peg$currPos += 7; + peg$currPos += (7); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e15); } @@ -1549,12 +1548,12 @@ function peg$parse(input, options) { } function peg$parsein() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c11) { - peg$currPos += 2; + peg$currPos += (2); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e16); } @@ -1569,12 +1568,12 @@ function peg$parse(input, options) { } function peg$parsecurrency() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c12) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e17); } @@ -1589,7 +1588,7 @@ function peg$parse(input, options) { } function peg$parsegroupCurrency() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 13) === peg$c13) { s0 = peg$c13; @@ -1602,7 +1601,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c14) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e19); } @@ -1618,12 +1617,12 @@ function peg$parse(input, options) { } function peg$parsetag() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); if (s1.toLowerCase() === peg$c15) { - peg$currPos += 3; + peg$currPos += (3); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e20); } @@ -1638,12 +1637,12 @@ function peg$parse(input, options) { } function peg$parsecategory() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c16) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e21); } @@ -1658,12 +1657,12 @@ function peg$parse(input, options) { } function peg$parseto() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c17) { - peg$currPos += 2; + peg$currPos += (2); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e22); } @@ -1678,12 +1677,12 @@ function peg$parse(input, options) { } function peg$parseexporter() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c18) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e23); } @@ -1698,12 +1697,12 @@ function peg$parse(input, options) { } function peg$parsepayer() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c19) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e24); } @@ -1718,11 +1717,11 @@ function peg$parse(input, options) { } function peg$parsetaxRate() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 7); if (s0.toLowerCase() === peg$c20) { - peg$currPos += 7; + peg$currPos += (7); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e25); } @@ -1731,7 +1730,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c21) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e26); } @@ -1747,7 +1746,7 @@ function peg$parse(input, options) { } function peg$parsecardID() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 6) === peg$c22) { s0 = peg$c22; @@ -1760,7 +1759,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c23) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e28); } @@ -1776,12 +1775,12 @@ function peg$parse(input, options) { } function peg$parsefrom() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c24) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e29); } @@ -1796,12 +1795,12 @@ function peg$parse(input, options) { } function peg$parseattendee() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c25) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e30); } @@ -1816,7 +1815,7 @@ function peg$parse(input, options) { } function peg$parseexpenseType() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 11) === peg$c26) { s0 = peg$c26; @@ -1829,7 +1828,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c27) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e32); } @@ -1845,7 +1844,7 @@ function peg$parse(input, options) { } function peg$parsewithdrawalType() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 14) === peg$c28) { s0 = peg$c28; @@ -1858,7 +1857,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c29) { - peg$currPos += 15; + peg$currPos += (15); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e34); } @@ -1874,11 +1873,11 @@ function peg$parse(input, options) { } function peg$parsewithdrawalID() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 12); if (s0.toLowerCase() === peg$c30) { - peg$currPos += 12; + peg$currPos += (12); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e35); } @@ -1887,7 +1886,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c31) { - peg$currPos += 13; + peg$currPos += (13); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e36); } @@ -1903,12 +1902,12 @@ function peg$parse(input, options) { } function peg$parsebillable() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c32) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e37); } @@ -1923,12 +1922,12 @@ function peg$parse(input, options) { } function peg$parsereimbursable() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c33) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e38); } @@ -1943,12 +1942,12 @@ function peg$parse(input, options) { } function peg$parsetype() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c34) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e39); } @@ -1963,12 +1962,12 @@ function peg$parse(input, options) { } function peg$parsestatus() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c35) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e40); } @@ -1983,7 +1982,7 @@ function peg$parse(input, options) { } function peg$parsesortBy() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 6) === peg$c36) { s0 = peg$c36; @@ -1996,7 +1995,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c37) { - peg$currPos += 7; + peg$currPos += (7); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e42); } @@ -2012,7 +2011,7 @@ function peg$parse(input, options) { } function peg$parsesortOrder() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 9) === peg$c38) { s0 = peg$c38; @@ -2025,7 +2024,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c39) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e44); } @@ -2041,7 +2040,7 @@ function peg$parse(input, options) { } function peg$parsepolicyID() { - var s0, s1; + let s0, s1; if (input.substr(peg$currPos, 8) === peg$c40) { s0 = peg$c40; @@ -2054,7 +2053,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c41) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e46); } @@ -2070,12 +2069,12 @@ function peg$parse(input, options) { } function peg$parsesubmitted() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c42) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e47); } @@ -2090,12 +2089,12 @@ function peg$parse(input, options) { } function peg$parseapproved() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c43) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e48); } @@ -2110,12 +2109,12 @@ function peg$parse(input, options) { } function peg$parsepaid() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c44) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e49); } @@ -2130,12 +2129,12 @@ function peg$parse(input, options) { } function peg$parseexported() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c45) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e50); } @@ -2150,12 +2149,12 @@ function peg$parse(input, options) { } function peg$parseposted() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c46) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e51); } @@ -2170,12 +2169,12 @@ function peg$parse(input, options) { } function peg$parsewithdrawn() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c47) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e52); } @@ -2190,11 +2189,11 @@ function peg$parse(input, options) { } function peg$parsegroupBy() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 7); if (s0.toLowerCase() === peg$c48) { - peg$currPos += 7; + peg$currPos += (7); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e53); } @@ -2203,7 +2202,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c49) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e54); } @@ -2219,12 +2218,12 @@ function peg$parse(input, options) { } function peg$parselimit() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c50) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e55); } @@ -2239,12 +2238,12 @@ function peg$parse(input, options) { } function peg$parsefeed() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c51) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e56); } @@ -2259,12 +2258,12 @@ function peg$parse(input, options) { } function peg$parsetitle() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c52) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e57); } @@ -2279,12 +2278,12 @@ function peg$parse(input, options) { } function peg$parseassignee() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c53) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e58); } @@ -2299,11 +2298,11 @@ function peg$parse(input, options) { } function peg$parsecreatedBy() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 9); if (s0.toLowerCase() === peg$c54) { - peg$currPos += 9; + peg$currPos += (9); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e59); } @@ -2312,7 +2311,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c55) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e60); } @@ -2328,12 +2327,12 @@ function peg$parse(input, options) { } function peg$parseaction() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c56) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e61); } @@ -2348,12 +2347,12 @@ function peg$parse(input, options) { } function peg$parsetotal() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c57) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e62); } @@ -2368,12 +2367,12 @@ function peg$parse(input, options) { } function peg$parsehas() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); if (s1.toLowerCase() === peg$c58) { - peg$currPos += 3; + peg$currPos += (3); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e63); } @@ -2388,12 +2387,12 @@ function peg$parse(input, options) { } function peg$parseis() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c59) { - peg$currPos += 2; + peg$currPos += (2); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e64); } @@ -2408,11 +2407,11 @@ function peg$parse(input, options) { } function peg$parsepurchaseAmount() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 14); if (s0.toLowerCase() === peg$c60) { - peg$currPos += 14; + peg$currPos += (14); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e65); } @@ -2421,7 +2420,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c61) { - peg$currPos += 15; + peg$currPos += (15); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e66); } @@ -2437,11 +2436,11 @@ function peg$parse(input, options) { } function peg$parsepurchaseCurrency() { - var s0, s1; + let s0, s1; s0 = input.substr(peg$currPos, 16); if (s0.toLowerCase() === peg$c62) { - peg$currPos += 16; + peg$currPos += (16); } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e67); } @@ -2450,7 +2449,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); if (s1.toLowerCase() === peg$c63) { - peg$currPos += 17; + peg$currPos += (17); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e68); } @@ -2466,12 +2465,12 @@ function peg$parse(input, options) { } function peg$parsecolumns() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c64) { - peg$currPos += 7; + peg$currPos += (7); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e69); } @@ -2486,12 +2485,12 @@ function peg$parse(input, options) { } function peg$parseview() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c65) { - peg$currPos += 4; + peg$currPos += (4); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e70); } @@ -2506,7 +2505,7 @@ function peg$parse(input, options) { } function peg$parsevalues() { - var s0, s1, s2; + let s0, s1, s2; s0 = peg$currPos; peg$savedPos = peg$currPos; @@ -2540,7 +2539,7 @@ function peg$parse(input, options) { } function peg$parsecolumnsValues() { - var s0; + let s0; s0 = peg$parseoriginalAmount(); if (s0 === peg$FAILED) { @@ -2626,12 +2625,12 @@ function peg$parse(input, options) { } function peg$parseperDiem() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c66) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e71); } @@ -2663,12 +2662,12 @@ function peg$parse(input, options) { } function peg$parsedraft() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c67) { - peg$currPos += 6; + peg$currPos += (6); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e72); } @@ -2676,7 +2675,7 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c68) { - peg$currPos += 5; + peg$currPos += (5); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e73); } @@ -2709,12 +2708,12 @@ function peg$parse(input, options) { } function peg$parseoriginalAmount() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c69) { - peg$currPos += 15; + peg$currPos += (15); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e74); } @@ -2746,12 +2745,12 @@ function peg$parse(input, options) { } function peg$parsetaxAmount() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); if (s1.toLowerCase() === peg$c70) { - peg$currPos += 3; + peg$currPos += (3); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e75); } @@ -2783,12 +2782,12 @@ function peg$parse(input, options) { } function peg$parsetaxRateValue() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c21) { - peg$currPos += 8; + peg$currPos += (8); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e26); } @@ -2820,12 +2819,12 @@ function peg$parse(input, options) { } function peg$parsepolicyName() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c71) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e76); } @@ -2857,12 +2856,12 @@ function peg$parse(input, options) { } function peg$parsewithdrawalId() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c31) { - peg$currPos += 13; + peg$currPos += (13); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e36); } @@ -2894,12 +2893,12 @@ function peg$parse(input, options) { } function peg$parsebankAccount() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c72) { - peg$currPos += 12; + peg$currPos += (12); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e77); } @@ -2931,12 +2930,12 @@ function peg$parse(input, options) { } function peg$parsereportId() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c73) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e78); } @@ -2968,12 +2967,12 @@ function peg$parse(input, options) { } function peg$parsebase62ReportId() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c9) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e14); } @@ -3005,12 +3004,12 @@ function peg$parse(input, options) { } function peg$parseexportedToColumnValue() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c74) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e79); } @@ -3042,7 +3041,7 @@ function peg$parse(input, options) { } function peg$parseexportedToKeyEnd() { - var s0, s1, s2; + let s0, s1, s2; s0 = peg$parsewordBoundary(); if (s0 === peg$FAILED) { @@ -3068,12 +3067,12 @@ function peg$parse(input, options) { } function peg$parseexportedTo() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c75) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e81); } @@ -3104,7 +3103,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c74) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e79); } @@ -3137,12 +3136,12 @@ function peg$parse(input, options) { } function peg$parseexchangeRate() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c76) { - peg$currPos += 13; + peg$currPos += (13); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e82); } @@ -3174,12 +3173,12 @@ function peg$parse(input, options) { } function peg$parsereimbursableTotal() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); if (s1.toLowerCase() === peg$c77) { - peg$currPos += 18; + peg$currPos += (18); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e83); } @@ -3211,12 +3210,12 @@ function peg$parse(input, options) { } function peg$parsenonReimbursableTotal() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 22); if (s1.toLowerCase() === peg$c78) { - peg$currPos += 22; + peg$currPos += (22); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e84); } @@ -3248,12 +3247,12 @@ function peg$parse(input, options) { } function peg$parsegroupFrom() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c79) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e85); } @@ -3285,12 +3284,12 @@ function peg$parse(input, options) { } function peg$parsegroupExpenses() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c80) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e86); } @@ -3322,12 +3321,12 @@ function peg$parse(input, options) { } function peg$parsegroupTotal() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c81) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e87); } @@ -3359,12 +3358,12 @@ function peg$parse(input, options) { } function peg$parsegroupCard() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c82) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e88); } @@ -3396,12 +3395,12 @@ function peg$parse(input, options) { } function peg$parsegroupFeed() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c83) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e89); } @@ -3433,12 +3432,12 @@ function peg$parse(input, options) { } function peg$parsegroupBankAccount() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); if (s1.toLowerCase() === peg$c84) { - peg$currPos += 18; + peg$currPos += (18); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e90); } @@ -3470,12 +3469,12 @@ function peg$parse(input, options) { } function peg$parsegroupWithdrawn() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c85) { - peg$currPos += 15; + peg$currPos += (15); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e91); } @@ -3507,12 +3506,12 @@ function peg$parse(input, options) { } function peg$parsegroupWithdrawalId() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 19); if (s1.toLowerCase() === peg$c86) { - peg$currPos += 19; + peg$currPos += (19); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e92); } @@ -3544,12 +3543,12 @@ function peg$parse(input, options) { } function peg$parsegroupCategory() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c87) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e93); } @@ -3581,12 +3580,12 @@ function peg$parse(input, options) { } function peg$parsegroupTag() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c88) { - peg$currPos += 9; + peg$currPos += (9); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e94); } @@ -3618,12 +3617,12 @@ function peg$parse(input, options) { } function peg$parsegroupMerchant() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c89) { - peg$currPos += 14; + peg$currPos += (14); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e95); } @@ -3655,12 +3654,12 @@ function peg$parse(input, options) { } function peg$parsegroupMonth() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c90) { - peg$currPos += 11; + peg$currPos += (11); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e96); } @@ -3692,12 +3691,12 @@ function peg$parse(input, options) { } function peg$parsegroupWeek() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c91) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e97); } @@ -3729,12 +3728,12 @@ function peg$parse(input, options) { } function peg$parsegroupYear() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c92) { - peg$currPos += 10; + peg$currPos += (10); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e98); } @@ -3766,12 +3765,12 @@ function peg$parse(input, options) { } function peg$parsegroupQuarter() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c93) { - peg$currPos += 13; + peg$currPos += (13); } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e99); } @@ -3803,16 +3802,16 @@ function peg$parse(input, options) { } function peg$parseoperator() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r2.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c94) { + s1 = peg$c94; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -3821,12 +3820,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c94) { - s1 = peg$c94; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r2.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e80); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -3849,9 +3848,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { + if (input.substr(peg$currPos, 2) === peg$c96) { s1 = peg$c96; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e103); } @@ -3863,9 +3862,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c97) { + if (input.charCodeAt(peg$currPos) === 62) { s1 = peg$c97; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e104); } @@ -3877,9 +3876,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { + if (input.substr(peg$currPos, 2) === peg$c98) { s1 = peg$c98; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e105); } @@ -3889,6 +3888,21 @@ function peg$parse(input, options) { s1 = peg$f93(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c99; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e106); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f94(); + } + s0 = s1; + } } } } @@ -3904,13 +3918,13 @@ function peg$parse(input, options) { } function peg$parsefilterOperator() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f94(s1); + s1 = peg$f95(s1); } s0 = s1; @@ -3918,7 +3932,7 @@ function peg$parse(input, options) { } function peg$parsealphanumeric() { - var s0, s1, s2; + let s0, s1, s2; peg$silentFails++; s0 = peg$currPos; @@ -3928,7 +3942,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -3938,7 +3952,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } } } else { @@ -3946,32 +3960,32 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f95(s1); + s1 = peg$f96(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e106); } + if (peg$silentFails === 0) { peg$fail(peg$e107); } } return s0; } function peg$parselogicalAnd() { - var s0, s1; + let s0, s1; s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f96(); + s1 = peg$f97(); s0 = s1; return s0; } function peg$parse_() { - var s0, s1; + let s0, s1; peg$silentFails++; s0 = []; @@ -3993,18 +4007,16 @@ function peg$parse(input, options) { } } peg$silentFails--; - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e108); } return s0; } function peg$parsequotedString() { - var s0, s1, s2; + let s0, s1, s2; s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f97(); + s1 = peg$f98(); if (s1) { s1 = undefined; } else { @@ -4031,7 +4043,7 @@ function peg$parse(input, options) { } function peg$parsesimpleQuotedString() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -4093,7 +4105,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -4102,11 +4114,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e107); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } } peg$savedPos = s0; - s0 = peg$f98(s1, s3, s5); + s0 = peg$f99(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4125,7 +4137,7 @@ function peg$parse(input, options) { } function peg$parsenestedQuotedString() { - var s0, s1, s2, s3, s4, s5, s6; + let s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -4177,7 +4189,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c99; + s6 = peg$c100; peg$currPos++; } else { s6 = peg$FAILED; @@ -4185,7 +4197,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f99(s1); + s4 = peg$f100(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4208,7 +4220,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c100; + s6 = peg$c101; peg$currPos++; } else { s6 = peg$FAILED; @@ -4216,7 +4228,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f100(s1); + s4 = peg$f101(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4239,7 +4251,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c101; + s6 = peg$c102; peg$currPos++; } else { s6 = peg$FAILED; @@ -4247,7 +4259,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f101(s1); + s4 = peg$f102(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4282,7 +4294,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c99; + s6 = peg$c100; peg$currPos++; } else { s6 = peg$FAILED; @@ -4290,7 +4302,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f99(s1); + s4 = peg$f100(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4313,7 +4325,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c100; + s6 = peg$c101; peg$currPos++; } else { s6 = peg$FAILED; @@ -4321,7 +4333,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f100(s1); + s4 = peg$f101(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4344,7 +4356,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c101; + s6 = peg$c102; peg$currPos++; } else { s6 = peg$FAILED; @@ -4352,7 +4364,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f101(s1); + s4 = peg$f102(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4368,7 +4380,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f102(s1, s3, s4); + s0 = peg$f103(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4387,7 +4399,7 @@ function peg$parse(input, options) { } function peg$parseclosingQuote() { - var s0, s1, s2, s3; + let s0, s1, s2, s3; s0 = peg$currPos; s1 = input.charAt(peg$currPos); @@ -4424,7 +4436,7 @@ function peg$parse(input, options) { } function peg$parsevalidClosingCondition() { - var s0, s1, s2, s3, s4; + let s0, s1, s2, s3, s4; s0 = peg$currPos; s1 = []; @@ -4562,7 +4574,7 @@ function peg$parse(input, options) { } function peg$parsewordBoundary() { - var s0, s1; + let s0, s1; s0 = input.charAt(peg$currPos); if (peg$r13.test(s0)) { @@ -4749,30 +4761,36 @@ function peg$parse(input, options) { peg$result = peg$startRuleFunction(); - if (options.peg$library) { - return /** @type {any} */ ({ - peg$result, - peg$currPos, - peg$FAILED, - peg$maxFailExpected, - peg$maxFailPos - }); - } - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { + const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); + function peg$throw() { if (peg$result !== peg$FAILED && peg$currPos < input.length) { peg$fail(peg$endExpectation()); } throw peg$buildStructuredError( peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, peg$maxFailPos < input.length ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) ); } + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos, + peg$success, + peg$throw: peg$success ? undefined : peg$throw, + }); + } + if (peg$success) { + return peg$result; + } else { + peg$throw(); + } } const peg$allowedStartRules = [ diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index c8971df55ea6..ec4f17efa6f2 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -688,7 +688,14 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); if (keyInCorrectForm) { - return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${sanitizeSearchValue(filterValue as string)}`; + // For merchant filters: + // - Non-negated: use *: (contains) for partial matching + // - Negated: use : (eq) so parser correctly converts to neq operator + const operator = + filterKey === FILTER_KEYS.MERCHANT && !isNegated + ? operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS] + : operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]; + return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}${operator}${sanitizeSearchValue(filterValue as string)}`; } } if ((filterKey === FILTER_KEYS.REPORT_ID || filterKey === FILTER_KEYS.WITHDRAWAL_ID) && filterValue) { diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 444323137e27..9cc14a24a681 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -182,7 +182,7 @@ describe('SearchQueryUtils', () => { const result = buildQueryStringFromFilterFormValues(filterValues); - expect(result).toEqual('type:expense policyID:67890 merchant:Amazon description:Electronics laptop category:electronics,gadgets'); + expect(result).toEqual('type:expense policyID:67890 merchant*:Amazon description:Electronics laptop category:electronics,gadgets'); }); test('currencies and categories', () => { @@ -323,7 +323,7 @@ describe('SearchQueryUtils', () => { const result = buildQueryStringFromFilterFormValues(filterValues, {sortBy: 'amount', sortOrder: 'asc', limit: 25}); - expect(result).toEqual('sortBy:amount sortOrder:asc type:expense merchant:Amazon limit:25'); + expect(result).toEqual('sortBy:amount sortOrder:asc type:expense merchant*:Amazon limit:25'); }); test('omits limit when not provided', () => { From 390eed169ff76619626343b14b98caabf10cd0c8 Mon Sep 17 00:00:00 2001 From: Phuoc Hiep Date: Fri, 20 Mar 2026 00:56:35 +0700 Subject: [PATCH 003/781] Regenerate parser files with Peggy 4.0.3 to match package.json Previously generated with Peggy 5.0.6 which caused AssertionError on compile. Regenerated using npm run generate-search-parser and generate-autocomplete-parser with installed Peggy 4.0.3. --- src/libs/SearchParser/autocompleteParser.js | 3232 +++++++++++++------ src/libs/SearchParser/searchParser.js | 1684 +++++----- 2 files changed, 3146 insertions(+), 1770 deletions(-) diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index 8bbd481fb0ab..ff2b2ebe4878 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -1,6 +1,7 @@ 'worklet' -// @generated by Peggy 5.0.6. +class peg$SyntaxError{} +// @generated by Peggy 4.0.3. // // https://peggyjs.org/ @@ -10,494 +11,601 @@ // To make changes, edit the corresponding *.peggy files only. // Use the `generate-search-parser` and `generate-autocomplete-parser` scripts to regenerate parsers after modifications. -class peg$SyntaxError extends SyntaxError { - constructor(message, expected, found, location) { - super(message); - this.expected = expected; - this.found = found; - this.location = location; - this.name = "SyntaxError"; - } - - format(sources) { - let str = "Error: " + this.message; - if (this.location) { - let src = null; - const st = sources.find(s => s.source === this.location.source); - if (st) { - src = st.text.split(/\r\n|\n|\r/g); - } - const s = this.location.start; - const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) - ? this.location.source.offset(s) - : s; - const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; - if (src) { - const e = this.location.end; - const filler = "".padEnd(offset_s.line.toString().length, " "); - const line = src[s.line - 1]; - const last = s.line === e.line ? e.column : line.length + 1; - const hatLen = (last - s.column) || 1; - str += "\n --> " + loc + "\n" - + filler + " |\n" - + offset_s.line + " | " + line + "\n" - + filler + " | " + "".padEnd(s.column - 1, " ") - + "".padEnd(hatLen, "^"); - } else { - str += "\n at " + loc; - } - } - return str; - } - - static buildMessage(expected, found) { - function hex(ch) { - return ch.codePointAt(0).toString(16).toUpperCase(); - } - - const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") - ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") - : null; - function unicodeEscape(s) { - if (nonPrintable) { - return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); - } - return s; - } - - function literalEscape(s) { - return unicodeEscape(s - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) - .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); - } - - function classEscape(s) { - return unicodeEscape(s - .replace(/\\/g, "\\\\") - .replace(/\]/g, "\\]") - .replace(/\^/g, "\\^") - .replace(/-/g, "\\-") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) - .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); - } - - const DESCRIBE_EXPECTATION_FNS = { - literal(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, +function peg$subclass(child, parent) { + function C() { this.constructor = child; } + C.prototype = parent.prototype; + child.prototype = new C(); +} - class(expectation) { - const escapedParts = expectation.parts.map( - part => (Array.isArray(part) - ? classEscape(part[0]) + "-" + classEscape(part[1]) - : classEscape(part)) - ); +function temporary(message, expected, found, location) { + var self = Error.call(this, message); + // istanbul ignore next Check is a necessary evil to support older environments + if (Object.setPrototypeOf) { + Object.setPrototypeOf(self, peg$SyntaxError.prototype); + } + self.expected = expected; + self.found = found; + self.location = location; + self.name = "SyntaxError"; + return self; +} - return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); - }, - any() { - return "any character"; - }, - end() { - return "end of input"; - }, +function peg$padEnd(str, targetLength, padString) { + padString = padString || " "; + if (str.length > targetLength) { return str; } + targetLength -= str.length; + padString += padString.repeat(targetLength); + return str + padString.slice(0, targetLength); +} - other(expectation) { - return expectation.description; - }, - }; +peg$SyntaxError.prototype.format = function(sources) { + var str = "Error: " + this.message; + if (this.location) { + var src = null; + var k; + for (k = 0; k < sources.length; k++) { + if (sources[k].source === this.location.source) { + src = sources[k].text.split(/\r\n|\n|\r/g); + break; + } + } + var s = this.location.start; + var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + var e = this.location.end; + var filler = peg$padEnd("", offset_s.line.toString().length, ' '); + var line = src[s.line - 1]; + var last = s.line === e.line ? e.column : line.length + 1; + var hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + peg$padEnd("", s.column - 1, ' ') + + peg$padEnd("", hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; +}; - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class: function(expectation) { + var escapedParts = expectation.parts.map(function(part) { + return Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part); + }); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; + }, + + any: function() { + return "any character"; + }, + + end: function() { + return "end of input"; + }, + + other: function(expectation) { + return expectation.description; } + }; - function describeExpected(expected) { - const descriptions = expected.map(describeExpectation); - descriptions.sort(); + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } - if (descriptions.length > 0) { - let j = 1; - for (let i = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } - } - descriptions.length = j; - } + function literalEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function classEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } - switch (descriptions.length) { - case 1: - return descriptions[0]; + function describeExpected(expected) { + var descriptions = expected.map(describeExpectation); + var i, j; - case 2: - return descriptions[0] + " or " + descriptions[1]; + descriptions.sort(); - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } } + descriptions.length = j; } - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; } + } - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; } -} + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; function peg$parse(input, options) { options = options !== undefined ? options : {}; - const peg$FAILED = {}; - const peg$source = options.grammarSource; + var peg$FAILED = {}; + var peg$source = options.grammarSource; + + var peg$startRuleFunctions = { query: peg$parsequery }; + var peg$startRuleFunction = peg$parsequery; + + var peg$c0 = "-"; + var peg$c1 = "report-field"; + var peg$c2 = "reportfield"; + var peg$c3 = ","; + var peg$c4 = "date"; + var peg$c5 = "amount"; + var peg$c6 = "merchant"; + var peg$c7 = "description"; + var peg$c8 = "reportid"; + var peg$c9 = "report-id"; + var peg$c10 = "keyword"; + var peg$c11 = "in"; + var peg$c12 = "currency"; + var peg$c13 = "groupCurrency"; + var peg$c14 = "group-currency"; + var peg$c15 = "tag"; + var peg$c16 = "category"; + var peg$c17 = "to"; + var peg$c18 = "exporter"; + var peg$c19 = "payer"; + var peg$c20 = "taxrate"; + var peg$c21 = "tax-rate"; + var peg$c22 = "cardID"; + var peg$c23 = "card"; + var peg$c24 = "from"; + var peg$c25 = "attendee"; + var peg$c26 = "expenseType"; + var peg$c27 = "expense-type"; + var peg$c28 = "withdrawalType"; + var peg$c29 = "withdrawal-type"; + var peg$c30 = "withdrawalid"; + var peg$c31 = "withdrawal-id"; + var peg$c32 = "billable"; + var peg$c33 = "reimbursable"; + var peg$c34 = "type"; + var peg$c35 = "status"; + var peg$c36 = "sortBy"; + var peg$c37 = "sort-by"; + var peg$c38 = "sortOrder"; + var peg$c39 = "sort-order"; + var peg$c40 = "policyID"; + var peg$c41 = "workspace"; + var peg$c42 = "submitted"; + var peg$c43 = "approved"; + var peg$c44 = "paid"; + var peg$c45 = "exported"; + var peg$c46 = "posted"; + var peg$c47 = "withdrawn"; + var peg$c48 = "groupby"; + var peg$c49 = "group-by"; + var peg$c50 = "limit"; + var peg$c51 = "feed"; + var peg$c52 = "title"; + var peg$c53 = "assignee"; + var peg$c54 = "createdby"; + var peg$c55 = "created-by"; + var peg$c56 = "action"; + var peg$c57 = "total"; + var peg$c58 = "has"; + var peg$c59 = "is"; + var peg$c60 = "purchaseamount"; + var peg$c61 = "purchase-amount"; + var peg$c62 = "purchasecurrency"; + var peg$c63 = "purchase-currency"; + var peg$c64 = "columns"; + var peg$c65 = "view"; + var peg$c66 = "per-diem"; + var peg$c67 = "drafts"; + var peg$c68 = "draft"; + var peg$c69 = "original-amount"; + var peg$c70 = "tax"; + var peg$c71 = "policy-name"; + var peg$c72 = "bank-account"; + var peg$c73 = "long-report-id"; + var peg$c74 = "exported-to"; + var peg$c75 = "exportedto"; + var peg$c76 = "exchange-rate"; + var peg$c77 = "reimbursable-total"; + var peg$c78 = "non-reimbursable-total"; + var peg$c79 = "group-from"; + var peg$c80 = "group-expenses"; + var peg$c81 = "group-total"; + var peg$c82 = "group-card"; + var peg$c83 = "group-feed"; + var peg$c84 = "group-bank-account"; + var peg$c85 = "group-withdrawn"; + var peg$c86 = "group-withdrawal-id"; + var peg$c87 = "group-category"; + var peg$c88 = "group-tag"; + var peg$c89 = "group-merchant"; + var peg$c90 = "group-month"; + var peg$c91 = "group-week"; + var peg$c92 = "group-year"; + var peg$c93 = "group-quarter"; + var peg$c94 = "*:"; + var peg$c95 = "!="; + var peg$c96 = ">="; + var peg$c97 = ">"; + var peg$c98 = "<="; + var peg$c99 = "<"; + var peg$c100 = "\u201C"; + var peg$c101 = "\u201D"; + var peg$c102 = "\""; + + var peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; + var peg$r1 = /^[:=]/; + var peg$r2 = /^[^ ,\t\n\r\xA0]/; + var peg$r3 = /^[ \t\r\n\xA0]/; + var peg$r4 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; + var peg$r5 = /^["\u201C-\u201D]/; + var peg$r6 = /^[^"\u201D\u201C\r\n]/; + var peg$r7 = /^[ \t\n\r\xA0a-zA-Z0-9]/; + var peg$r8 = /^[a-zA-Z0-9]/; + var peg$r9 = /^[ \t\n\r\xA0]/; + var peg$r10 = /^[ \t\n\r\xA0a-zA-Z]/; + var peg$r11 = /^[,]/; + var peg$r12 = /^[ \t\n\r\xA0,]/; + + var peg$e0 = peg$literalExpectation("-", false); + var peg$e1 = peg$literalExpectation("report-field", true); + var peg$e2 = peg$literalExpectation("reportfield", true); + var peg$e3 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false); + var peg$e4 = peg$anyExpectation(); + var peg$e5 = peg$literalExpectation(",", false); + var peg$e6 = peg$otherExpectation("key"); + var peg$e7 = peg$literalExpectation("date", true); + var peg$e8 = peg$literalExpectation("amount", true); + var peg$e9 = peg$literalExpectation("merchant", true); + var peg$e10 = peg$literalExpectation("description", true); + var peg$e11 = peg$literalExpectation("reportid", true); + var peg$e12 = peg$literalExpectation("report-id", true); + var peg$e13 = peg$literalExpectation("keyword", true); + var peg$e14 = peg$literalExpectation("in", true); + var peg$e15 = peg$literalExpectation("currency", true); + var peg$e16 = peg$literalExpectation("groupCurrency", false); + var peg$e17 = peg$literalExpectation("group-currency", true); + var peg$e18 = peg$literalExpectation("tag", true); + var peg$e19 = peg$literalExpectation("category", true); + var peg$e20 = peg$literalExpectation("to", true); + var peg$e21 = peg$literalExpectation("exporter", true); + var peg$e22 = peg$literalExpectation("payer", true); + var peg$e23 = peg$literalExpectation("taxRate", true); + var peg$e24 = peg$literalExpectation("tax-rate", true); + var peg$e25 = peg$literalExpectation("cardID", false); + var peg$e26 = peg$literalExpectation("card", true); + var peg$e27 = peg$literalExpectation("from", true); + var peg$e28 = peg$literalExpectation("attendee", true); + var peg$e29 = peg$literalExpectation("expenseType", false); + var peg$e30 = peg$literalExpectation("expense-type", true); + var peg$e31 = peg$literalExpectation("withdrawalType", false); + var peg$e32 = peg$literalExpectation("withdrawal-type", true); + var peg$e33 = peg$literalExpectation("withdrawalID", true); + var peg$e34 = peg$literalExpectation("withdrawal-id", true); + var peg$e35 = peg$literalExpectation("billable", true); + var peg$e36 = peg$literalExpectation("reimbursable", true); + var peg$e37 = peg$literalExpectation("type", true); + var peg$e38 = peg$literalExpectation("status", true); + var peg$e39 = peg$literalExpectation("sortBy", false); + var peg$e40 = peg$literalExpectation("sort-by", true); + var peg$e41 = peg$literalExpectation("sortOrder", false); + var peg$e42 = peg$literalExpectation("sort-order", true); + var peg$e43 = peg$literalExpectation("policyID", false); + var peg$e44 = peg$literalExpectation("workspace", true); + var peg$e45 = peg$literalExpectation("submitted", true); + var peg$e46 = peg$literalExpectation("approved", true); + var peg$e47 = peg$literalExpectation("paid", true); + var peg$e48 = peg$literalExpectation("exported", true); + var peg$e49 = peg$literalExpectation("posted", true); + var peg$e50 = peg$literalExpectation("withdrawn", true); + var peg$e51 = peg$literalExpectation("groupBy", true); + var peg$e52 = peg$literalExpectation("group-by", true); + var peg$e53 = peg$literalExpectation("limit", true); + var peg$e54 = peg$literalExpectation("feed", true); + var peg$e55 = peg$literalExpectation("title", true); + var peg$e56 = peg$literalExpectation("assignee", true); + var peg$e57 = peg$literalExpectation("createdBy", true); + var peg$e58 = peg$literalExpectation("created-by", true); + var peg$e59 = peg$literalExpectation("action", true); + var peg$e60 = peg$literalExpectation("total", true); + var peg$e61 = peg$literalExpectation("has", true); + var peg$e62 = peg$literalExpectation("is", true); + var peg$e63 = peg$literalExpectation("purchaseAmount", true); + var peg$e64 = peg$literalExpectation("purchase-amount", true); + var peg$e65 = peg$literalExpectation("purchaseCurrency", true); + var peg$e66 = peg$literalExpectation("purchase-currency", true); + var peg$e67 = peg$literalExpectation("columns", true); + var peg$e68 = peg$literalExpectation("view", true); + var peg$e69 = peg$literalExpectation("per-diem", true); + var peg$e70 = peg$literalExpectation("drafts", true); + var peg$e71 = peg$literalExpectation("draft", true); + var peg$e72 = peg$literalExpectation("original-amount", true); + var peg$e73 = peg$literalExpectation("tax", true); + var peg$e74 = peg$literalExpectation("policy-name", true); + var peg$e75 = peg$literalExpectation("bank-account", true); + var peg$e76 = peg$literalExpectation("long-report-id", true); + var peg$e77 = peg$literalExpectation("exported-to", true); + var peg$e78 = peg$classExpectation([":", "="], false, false); + var peg$e79 = peg$literalExpectation("exportedTo", true); + var peg$e80 = peg$literalExpectation("exchange-rate", true); + var peg$e81 = peg$literalExpectation("reimbursable-total", true); + var peg$e82 = peg$literalExpectation("non-reimbursable-total", true); + var peg$e83 = peg$literalExpectation("group-from", true); + var peg$e84 = peg$literalExpectation("group-expenses", true); + var peg$e85 = peg$literalExpectation("group-total", true); + var peg$e86 = peg$literalExpectation("group-card", true); + var peg$e87 = peg$literalExpectation("group-feed", true); + var peg$e88 = peg$literalExpectation("group-bank-account", true); + var peg$e89 = peg$literalExpectation("group-withdrawn", true); + var peg$e90 = peg$literalExpectation("group-withdrawal-id", true); + var peg$e91 = peg$literalExpectation("group-category", true); + var peg$e92 = peg$literalExpectation("group-tag", true); + var peg$e93 = peg$literalExpectation("group-merchant", true); + var peg$e94 = peg$literalExpectation("group-month", true); + var peg$e95 = peg$literalExpectation("group-week", true); + var peg$e96 = peg$literalExpectation("group-year", true); + var peg$e97 = peg$literalExpectation("group-quarter", true); + var peg$e98 = peg$otherExpectation("operator"); + var peg$e99 = peg$literalExpectation("*:", false); + var peg$e100 = peg$literalExpectation("!=", false); + var peg$e101 = peg$literalExpectation(">=", false); + var peg$e102 = peg$literalExpectation(">", false); + var peg$e103 = peg$literalExpectation("<=", false); + var peg$e104 = peg$literalExpectation("<", false); + var peg$e105 = peg$otherExpectation("word"); + var peg$e106 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e107 = peg$otherExpectation("whitespace"); + var peg$e108 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e109 = peg$otherExpectation("quote"); + var peg$e110 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e111 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e112 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e113 = peg$literalExpectation("\u201C", false); + var peg$e114 = peg$literalExpectation("\u201D", false); + var peg$e115 = peg$literalExpectation("\"", false); + var peg$e116 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e117 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e118 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e119 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e120 = peg$classExpectation([","], false, false); + var peg$e121 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + + var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; + var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; + var peg$f2 = function(neg, key, op, value) { + expectingNestedQuote = false; nameOperator = false; // Reset string parser + + const isNegated = !!neg && !nonNegatableKeys.has(key); + + if (!value) { + autocomplete = { + key, + negated: isNegated, + value: "", + start: location().end.offset, + length: 0, + }; + return; + } - const peg$startRuleFunctions = { - query: peg$parsequery, - }; - let peg$startRuleFunction = peg$parsequery; - - const peg$c0 = "-"; - const peg$c1 = "report-field"; - const peg$c2 = "reportfield"; - const peg$c3 = ","; - const peg$c4 = "date"; - const peg$c5 = "amount"; - const peg$c6 = "merchant"; - const peg$c7 = "description"; - const peg$c8 = "reportid"; - const peg$c9 = "report-id"; - const peg$c10 = "in"; - const peg$c11 = "currency"; - const peg$c12 = "groupCurrency"; - const peg$c13 = "group-currency"; - const peg$c14 = "tag"; - const peg$c15 = "category"; - const peg$c16 = "to"; - const peg$c17 = "exporter"; - const peg$c18 = "payer"; - const peg$c19 = "taxrate"; - const peg$c20 = "tax-rate"; - const peg$c21 = "cardID"; - const peg$c22 = "card"; - const peg$c23 = "from"; - const peg$c24 = "attendee"; - const peg$c25 = "expenseType"; - const peg$c26 = "expense-type"; - const peg$c27 = "withdrawalType"; - const peg$c28 = "withdrawal-type"; - const peg$c29 = "withdrawalid"; - const peg$c30 = "withdrawal-id"; - const peg$c31 = "billable"; - const peg$c32 = "reimbursable"; - const peg$c33 = "type"; - const peg$c34 = "status"; - const peg$c35 = "policyID"; - const peg$c36 = "workspace"; - const peg$c37 = "submitted"; - const peg$c38 = "approved"; - const peg$c39 = "paid"; - const peg$c40 = "exported"; - const peg$c41 = "posted"; - const peg$c42 = "withdrawn"; - const peg$c43 = "groupby"; - const peg$c44 = "group-by"; - const peg$c45 = "limit"; - const peg$c46 = "feed"; - const peg$c47 = "title"; - const peg$c48 = "assignee"; - const peg$c49 = "createdby"; - const peg$c50 = "created-by"; - const peg$c51 = "action"; - const peg$c52 = "total"; - const peg$c53 = "has"; - const peg$c54 = "is"; - const peg$c55 = "purchaseamount"; - const peg$c56 = "purchase-amount"; - const peg$c57 = "purchasecurrency"; - const peg$c58 = "purchase-currency"; - const peg$c59 = "columns"; - const peg$c60 = "view"; - const peg$c61 = "exportedto"; - const peg$c62 = "exported-to"; - const peg$c63 = "*:"; - const peg$c64 = "!="; - const peg$c65 = ">="; - const peg$c66 = ">"; - const peg$c67 = "<="; - const peg$c68 = "<"; - const peg$c69 = "\u201C"; - const peg$c70 = "\u201D"; - const peg$c71 = "\""; - - const peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; - const peg$r1 = /^[:=]/; - const peg$r2 = /^[^ ,\t\n\r\xA0]/; - const peg$r3 = /^[ \t\r\n\xA0]/; - const peg$r4 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; - const peg$r5 = /^["\u201C-\u201D]/; - const peg$r6 = /^[^"\u201D\u201C\r\n]/; - const peg$r7 = /^[ \t\n\r\xA0a-zA-Z0-9]/; - const peg$r8 = /^[a-zA-Z0-9]/; - const peg$r9 = /^[ \t\n\r\xA0]/; - const peg$r10 = /^[ \t\n\r\xA0a-zA-Z]/; - const peg$r11 = /^[,]/; - const peg$r12 = /^[ \t\n\r\xA0,]/; - - const peg$e0 = peg$literalExpectation("-", false); - const peg$e1 = peg$literalExpectation("report-field", true); - const peg$e2 = peg$literalExpectation("reportfield", true); - const peg$e3 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false, false); - const peg$e4 = peg$anyExpectation(); - const peg$e5 = peg$literalExpectation(",", false); - const peg$e6 = peg$otherExpectation("key"); - const peg$e7 = peg$literalExpectation("date", true); - const peg$e8 = peg$literalExpectation("amount", true); - const peg$e9 = peg$literalExpectation("merchant", true); - const peg$e10 = peg$literalExpectation("description", true); - const peg$e11 = peg$literalExpectation("reportid", true); - const peg$e12 = peg$literalExpectation("report-id", true); - const peg$e13 = peg$literalExpectation("in", true); - const peg$e14 = peg$literalExpectation("currency", true); - const peg$e15 = peg$literalExpectation("groupCurrency", false); - const peg$e16 = peg$literalExpectation("group-currency", true); - const peg$e17 = peg$literalExpectation("tag", true); - const peg$e18 = peg$literalExpectation("category", true); - const peg$e19 = peg$literalExpectation("to", true); - const peg$e20 = peg$literalExpectation("exporter", true); - const peg$e21 = peg$literalExpectation("payer", true); - const peg$e22 = peg$literalExpectation("taxRate", true); - const peg$e23 = peg$literalExpectation("tax-rate", true); - const peg$e24 = peg$literalExpectation("cardID", false); - const peg$e25 = peg$literalExpectation("card", true); - const peg$e26 = peg$literalExpectation("from", true); - const peg$e27 = peg$literalExpectation("attendee", true); - const peg$e28 = peg$literalExpectation("expenseType", false); - const peg$e29 = peg$literalExpectation("expense-type", true); - const peg$e30 = peg$literalExpectation("withdrawalType", false); - const peg$e31 = peg$literalExpectation("withdrawal-type", true); - const peg$e32 = peg$literalExpectation("withdrawalID", true); - const peg$e33 = peg$literalExpectation("withdrawal-id", true); - const peg$e34 = peg$literalExpectation("billable", true); - const peg$e35 = peg$literalExpectation("reimbursable", true); - const peg$e36 = peg$literalExpectation("type", true); - const peg$e37 = peg$literalExpectation("status", true); - const peg$e38 = peg$literalExpectation("policyID", false); - const peg$e39 = peg$literalExpectation("workspace", true); - const peg$e40 = peg$literalExpectation("submitted", true); - const peg$e41 = peg$literalExpectation("approved", true); - const peg$e42 = peg$literalExpectation("paid", true); - const peg$e43 = peg$literalExpectation("exported", true); - const peg$e44 = peg$literalExpectation("posted", true); - const peg$e45 = peg$literalExpectation("withdrawn", true); - const peg$e46 = peg$literalExpectation("groupBy", true); - const peg$e47 = peg$literalExpectation("group-by", true); - const peg$e48 = peg$literalExpectation("limit", true); - const peg$e49 = peg$literalExpectation("feed", true); - const peg$e50 = peg$literalExpectation("title", true); - const peg$e51 = peg$literalExpectation("assignee", true); - const peg$e52 = peg$literalExpectation("createdBy", true); - const peg$e53 = peg$literalExpectation("created-by", true); - const peg$e54 = peg$literalExpectation("action", true); - const peg$e55 = peg$literalExpectation("total", true); - const peg$e56 = peg$literalExpectation("has", true); - const peg$e57 = peg$literalExpectation("is", true); - const peg$e58 = peg$literalExpectation("purchaseAmount", true); - const peg$e59 = peg$literalExpectation("purchase-amount", true); - const peg$e60 = peg$literalExpectation("purchaseCurrency", true); - const peg$e61 = peg$literalExpectation("purchase-currency", true); - const peg$e62 = peg$literalExpectation("columns", true); - const peg$e63 = peg$literalExpectation("view", true); - const peg$e64 = peg$classExpectation([":", "="], false, false, false); - const peg$e65 = peg$literalExpectation("exportedTo", true); - const peg$e66 = peg$literalExpectation("exported-to", true); - const peg$e67 = peg$otherExpectation("operator"); - const peg$e68 = peg$literalExpectation("*:", false); - const peg$e69 = peg$literalExpectation("!=", false); - const peg$e70 = peg$literalExpectation(">=", false); - const peg$e71 = peg$literalExpectation(">", false); - const peg$e72 = peg$literalExpectation("<=", false); - const peg$e73 = peg$literalExpectation("<", false); - const peg$e74 = peg$otherExpectation("word"); - const peg$e75 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false, false); - const peg$e76 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false, false); - const peg$e77 = peg$otherExpectation("quote"); - const peg$e78 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false, false); - const peg$e79 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false, false); - const peg$e80 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false, false); - const peg$e81 = peg$literalExpectation("\u201C", false); - const peg$e82 = peg$literalExpectation("\u201D", false); - const peg$e83 = peg$literalExpectation("\"", false); - const peg$e84 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); - const peg$e85 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); - const peg$e86 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false, false); - const peg$e87 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false, false); - const peg$e88 = peg$classExpectation([","], false, false, false); - const peg$e89 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false, false); - - function peg$f0(ranges) { return { autocomplete, ranges }; } - function peg$f1(filters) { return filters.filter(Boolean).flat(); } - function peg$f2(neg, key, op, value) { - expectingNestedQuote = false; nameOperator = false; // Reset string parser - - const isNegated = !!neg && !nonNegatableKeys.has(key); - - if (!value) { autocomplete = { key, negated: isNegated, - value: "", - start: location().end.offset, - length: 0, + ...value[value.length - 1], }; - return; - } - - autocomplete = { - key, - negated: isNegated, - ...value[value.length - 1], + return value + .filter((v) => v.length > 0) + .map((v) => ({ + key, + negated: isNegated, + ...v, + })); }; - return value - .filter((v) => v.length > 0) - .map((v) => ({ - key, - negated: isNegated, - ...v, - })); - } - function peg$f3(rest) { - const suffix = rest.replaceAll(/^-+/g, ""); - return "reportField-" + (suffix ? suffix : ""); - } - function peg$f4() { autocomplete = null; } - function peg$f5(k) { - nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); - return k; - } - function peg$f6(parts, empty) { - const ends = location(); - const value = parts.flat().filter(Boolean); // Filter out undefined values returned by the predicate - if (empty) { - value.push(""); - } - let count = ends.start.offset; - const result = []; - value.forEach((filter) => { - let word = filter; - if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { - word = word.slice(1, -1); - } - result.push({ - value: word, - start: count, - length: filter.length, + var peg$f3 = function(rest) { + const suffix = rest.replaceAll(/^-+/g, ""); + return "reportField-" + (suffix ? suffix : ""); + }; + var peg$f4 = function() { autocomplete = null; }; + var peg$f5 = function(k) { + nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); + return k; + }; + var peg$f6 = function(parts, empty) { + const ends = location(); + const value = parts.flat().filter(Boolean); // Filter out undefined values returned by the predicate + if (empty) { + value.push(""); + } + let count = ends.start.offset; + const result = []; + value.forEach((filter) => { + let word = filter; + if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { + word = word.slice(1, -1); + } + result.push({ + value: word, + start: count, + length: filter.length, + }); + count += filter.length + 1; }); - count += filter.length + 1; - }); - return result; - } - function peg$f7() { return "date"; } - function peg$f8() { return "amount"; } - function peg$f9() { return "merchant"; } - function peg$f10() { return "description"; } - function peg$f11() { return "reportID"; } - function peg$f12() { return "in"; } - function peg$f13() { return "currency"; } - function peg$f14() { return "groupCurrency"; } - function peg$f15() { return "tag"; } - function peg$f16() { return "category"; } - function peg$f17() { return "to"; } - function peg$f18() { return "exporter"; } - function peg$f19() { return "payer"; } - function peg$f20() { return "taxRate"; } - function peg$f21() { return "cardID"; } - function peg$f22() { return "from"; } - function peg$f23() {return "attendee" } - function peg$f24() { return "expenseType"; } - function peg$f25() { return "withdrawalType"; } - function peg$f26() { return "withdrawalID"; } - function peg$f27() { return "billable"; } - function peg$f28() { return "reimbursable"; } - function peg$f29() { return "type"; } - function peg$f30() { return "status"; } - function peg$f31() { return "policyID"; } - function peg$f32() { return "submitted"; } - function peg$f33() { return "approved"; } - function peg$f34() { return "paid"; } - function peg$f35() { return "exported"; } - function peg$f36() { return "posted"; } - function peg$f37() { return "withdrawn"; } - function peg$f38() { return "groupBy"; } - function peg$f39() { return "limit"; } - function peg$f40() { return "feed"; } - function peg$f41() { return "title"; } - function peg$f42() { return "assignee"; } - function peg$f43() { return "createdBy"; } - function peg$f44() { return "action"; } - function peg$f45() {return "total"; } - function peg$f46() {return "has"; } - function peg$f47() {return "is"; } - function peg$f48() {return "purchaseAmount" } - function peg$f49() {return "purchaseCurrency" } - function peg$f50() { - isColumnsContext = true; - return "columns"; - } - function peg$f51() {return "view" } - function peg$f52() { return "exportedTo"; } - function peg$f53() { return "exportedTo"; } - function peg$f54() { return "contains"; } - function peg$f55() { return "eq"; } - function peg$f56() { return "neq"; } - function peg$f57() { return "gte"; } - function peg$f58() { return "gt"; } - function peg$f59() { return "lte"; } - function peg$f60() { return "lt"; } - function peg$f61(o) { - if (nameOperator) { - expectingNestedQuote = (o === "eq" || o === "contains"); - } - isColumnsContext = false; - return o; - } - function peg$f62(chars) { return chars.join("").trim(); } - function peg$f63() { - isColumnsContext = false; - return "and"; - } - function peg$f64() { return expectingNestedQuote; } - function peg$f65(start, inner, end) { //handle no-breaking space - return [...start, '"', ...inner, '"', ...end].join(""); - } - function peg$f66(start) {return "“" } - function peg$f67(start) {return "”" } - function peg$f68(start) {return "\"" } - function peg$f69(start, inner, end) { + return result; + }; + var peg$f7 = function() { return "date"; }; + var peg$f8 = function() { return "amount"; }; + var peg$f9 = function() { return "merchant"; }; + var peg$f10 = function() { return "description"; }; + var peg$f11 = function() { return "reportID"; }; + var peg$f12 = function() { return "keyword"; }; + var peg$f13 = function() { return "in"; }; + var peg$f14 = function() { return "currency"; }; + var peg$f15 = function() { return "groupCurrency"; }; + var peg$f16 = function() { return "tag"; }; + var peg$f17 = function() { return "category"; }; + var peg$f18 = function() { return "to"; }; + var peg$f19 = function() { return "exporter"; }; + var peg$f20 = function() { return "payer"; }; + var peg$f21 = function() { return "taxRate"; }; + var peg$f22 = function() { return "cardID"; }; + var peg$f23 = function() { return "from"; }; + var peg$f24 = function() {return "attendee"}; + var peg$f25 = function() { return "expenseType"; }; + var peg$f26 = function() { return "withdrawalType"; }; + var peg$f27 = function() { return "withdrawalID"; }; + var peg$f28 = function() { return "billable"; }; + var peg$f29 = function() { return "reimbursable"; }; + var peg$f30 = function() { return "type"; }; + var peg$f31 = function() { return "status"; }; + var peg$f32 = function() { return "sortBy"; }; + var peg$f33 = function() { return "sortOrder"; }; + var peg$f34 = function() { return "policyID"; }; + var peg$f35 = function() { return "submitted"; }; + var peg$f36 = function() { return "approved"; }; + var peg$f37 = function() { return "paid"; }; + var peg$f38 = function() { return "exported"; }; + var peg$f39 = function() { return "posted"; }; + var peg$f40 = function() { return "withdrawn"; }; + var peg$f41 = function() { return "groupBy"; }; + var peg$f42 = function() { return "limit"; }; + var peg$f43 = function() { return "feed"; }; + var peg$f44 = function() { return "title"; }; + var peg$f45 = function() { return "assignee"; }; + var peg$f46 = function() { return "createdBy"; }; + var peg$f47 = function() { return "action"; }; + var peg$f48 = function() {return "total"; }; + var peg$f49 = function() {return "has"; }; + var peg$f50 = function() {return "is"; }; + var peg$f51 = function() {return "purchaseAmount"}; + var peg$f52 = function() {return "purchaseCurrency"}; + var peg$f53 = function() { + isColumnsContext = true; + return "columns"; +}; + var peg$f54 = function() {return "view"}; + var peg$f55 = function() { return isColumnsContext; }; + var peg$f56 = function() { return "perDiem"; }; + var peg$f57 = function() { return "drafts"; }; + var peg$f58 = function() { return "originalamount"; }; + var peg$f59 = function() { return "taxAmount"; }; + var peg$f60 = function() { return "taxrate"; }; + var peg$f61 = function() { return "policyname"; }; + var peg$f62 = function() { return "withdrawalID"; }; + var peg$f63 = function() { return "bankAccount"; }; + var peg$f64 = function() { return "reportID"; }; + var peg$f65 = function() { return "base62ReportID"; }; + var peg$f66 = function() { return "exportedto"; }; + var peg$f67 = function() { return "exportedTo"; }; + var peg$f68 = function() { return "exportedTo"; }; + var peg$f69 = function() { return "exchangeRate"; }; + var peg$f70 = function() { return "reimbursableTotal"; }; + var peg$f71 = function() { return "nonReimbursableTotal"; }; + var peg$f72 = function() { return "groupFrom"; }; + var peg$f73 = function() { return "groupExpenses"; }; + var peg$f74 = function() { return "groupTotal"; }; + var peg$f75 = function() { return "groupCard"; }; + var peg$f76 = function() { return "groupFeed"; }; + var peg$f77 = function() { return "groupBankAccount"; }; + var peg$f78 = function() { return "groupWithdrawn"; }; + var peg$f79 = function() { return "groupWithdrawalID"; }; + var peg$f80 = function() { return "groupCategory"; }; + var peg$f81 = function() { return "groupTag"; }; + var peg$f82 = function() { return "groupMerchant"; }; + var peg$f83 = function() { return "groupmonth"; }; + var peg$f84 = function() { return "groupweek"; }; + var peg$f85 = function() { return "groupyear"; }; + var peg$f86 = function() { return "groupquarter"; }; + var peg$f87 = function() { return "contains"; }; + var peg$f88 = function() { return "eq"; }; + var peg$f89 = function() { return "neq"; }; + var peg$f90 = function() { return "gte"; }; + var peg$f91 = function() { return "gt"; }; + var peg$f92 = function() { return "lte"; }; + var peg$f93 = function() { return "lt"; }; + var peg$f94 = function(o) { + if (nameOperator) { + expectingNestedQuote = (o === "eq" || o === "contains"); + } + isColumnsContext = false; + return o; + }; + var peg$f95 = function(chars) { return chars.join("").trim(); }; + var peg$f96 = function() { + isColumnsContext = false; + return "and"; +}; + var peg$f97 = function() { return expectingNestedQuote; }; + var peg$f98 = function(start, inner, end) { //handle no-breaking space + return [...start, '"', ...inner, '"', ...end].join(""); + }; + var peg$f99 = function(start) {return "“"}; + var peg$f100 = function(start) {return "”"}; + var peg$f101 = function(start) {return "\""}; + var peg$f102 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); - } - let peg$currPos = options.peg$currPos | 0; - let peg$savedPos = peg$currPos; - const peg$posDetailsCache = [{ line: 1, column: 1 }]; - let peg$maxFailPos = peg$currPos; - let peg$maxFailExpected = options.peg$maxFailExpected || []; - let peg$silentFails = options.peg$silentFails | 0; +}; + var peg$currPos = options.peg$currPos | 0; + var peg$savedPos = peg$currPos; + var peg$posDetailsCache = [{ line: 1, column: 1 }]; + var peg$maxFailPos = peg$currPos; + var peg$maxFailExpected = options.peg$maxFailExpected || []; + var peg$silentFails = options.peg$silentFails | 0; - let peg$result; + var peg$result; if (options.startRule) { if (!(options.startRule in peg$startRuleFunctions)) { @@ -519,7 +627,7 @@ function peg$parse(input, options) { return { source: peg$source, start: peg$savedPos, - end: peg$currPos, + end: peg$currPos }; } @@ -547,20 +655,12 @@ function peg$parse(input, options) { throw peg$buildSimpleError(message, location); } - function peg$getUnicode(pos = peg$currPos) { - const cp = input.codePointAt(pos); - if (cp === undefined) { - return ""; - } - return String.fromCodePoint(cp); - } - function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text, ignoreCase }; + return { type: "literal", text: text, ignoreCase: ignoreCase }; } - function peg$classExpectation(parts, inverted, ignoreCase, unicode) { - return { type: "class", parts, inverted, ignoreCase, unicode }; + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; } function peg$anyExpectation() { @@ -572,12 +672,12 @@ function peg$parse(input, options) { } function peg$otherExpectation(description) { - return { type: "other", description }; + return { type: "other", description: description }; } function peg$computePosDetails(pos) { - let details = peg$posDetailsCache[pos]; - let p; + var details = peg$posDetailsCache[pos]; + var p; if (details) { return details; @@ -592,7 +692,7 @@ function peg$parse(input, options) { details = peg$posDetailsCache[p]; details = { line: details.line, - column: details.column, + column: details.column }; while (p < pos) { @@ -613,21 +713,21 @@ function peg$parse(input, options) { } function peg$computeLocation(startPos, endPos, offset) { - const startPosDetails = peg$computePosDetails(startPos); - const endPosDetails = peg$computePosDetails(endPos); + var startPosDetails = peg$computePosDetails(startPos); + var endPosDetails = peg$computePosDetails(endPos); - const res = { + var res = { source: peg$source, start: { offset: startPos, line: startPosDetails.line, - column: startPosDetails.column, + column: startPosDetails.column }, end: { offset: endPos, line: endPosDetails.line, - column: endPosDetails.column, - }, + column: endPosDetails.column + } }; if (offset && peg$source && (typeof peg$source.offset === "function")) { res.start = peg$source.offset(res.start); @@ -661,7 +761,7 @@ function peg$parse(input, options) { } function peg$parsequery() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -674,7 +774,7 @@ function peg$parse(input, options) { } function peg$parsefilterList() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = []; @@ -699,7 +799,7 @@ function peg$parse(input, options) { } function peg$parsefilter() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = peg$parsedefaultFilter(); @@ -717,7 +817,7 @@ function peg$parse(input, options) { } function peg$parsedefaultFilter() { - let s0, s1, s2, s3, s4, s5, s6, s7; + var s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = peg$parse_(); @@ -756,12 +856,12 @@ function peg$parse(input, options) { } function peg$parsereportFieldDynamic() { - let s0, s1, s2, s3, s4, s5, s6, s7; + var s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c1) { - peg$currPos += (12); + peg$currPos += 12; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e1); } @@ -769,7 +869,7 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c2) { - peg$currPos += (11); + peg$currPos += 11; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e2); } @@ -877,7 +977,7 @@ function peg$parse(input, options) { } function peg$parsefreeTextFilter() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -904,7 +1004,7 @@ function peg$parse(input, options) { } function peg$parseautocompleteKey() { - let s0, s1; + var s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -1063,7 +1163,7 @@ function peg$parse(input, options) { } function peg$parsefilterKey() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = peg$parseautocompleteKey(); @@ -1077,7 +1177,7 @@ function peg$parse(input, options) { } function peg$parseidentifier() { - let s0, s1, s2, s3, s4; + var s0, s1, s2, s3, s4; s0 = peg$currPos; s1 = peg$currPos; @@ -1139,12 +1239,12 @@ function peg$parse(input, options) { } function peg$parsedate() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c4) { - peg$currPos += (4); + peg$currPos += 4; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e7); } @@ -1159,12 +1259,12 @@ function peg$parse(input, options) { } function peg$parseamount() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c5) { - peg$currPos += (6); + peg$currPos += 6; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e8); } @@ -1179,12 +1279,12 @@ function peg$parse(input, options) { } function peg$parsemerchant() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c6) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e9); } @@ -1199,12 +1299,12 @@ function peg$parse(input, options) { } function peg$parsedescription() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c7) { - peg$currPos += (11); + peg$currPos += 11; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e10); } @@ -1219,11 +1319,11 @@ function peg$parse(input, options) { } function peg$parsereportID() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 8); if (s0.toLowerCase() === peg$c8) { - peg$currPos += (8); + peg$currPos += 8; } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e11); } @@ -1232,7 +1332,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c9) { - peg$currPos += (9); + peg$currPos += 9; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e12); } @@ -1247,13 +1347,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsein() { - let s0, s1; + function peg$parsekeyword() { + var s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 2); + s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c10) { - peg$currPos += (2); + peg$currPos += 7; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e13); } @@ -1267,13 +1367,13 @@ function peg$parse(input, options) { return s0; } - function peg$parsecurrency() { - let s0, s1; + function peg$parsein() { + var s0, s1; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 8); + s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c11) { - peg$currPos += (8); + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e14); } @@ -1287,28 +1387,48 @@ function peg$parse(input, options) { return s0; } + function peg$parsecurrency() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 8); + if (s1.toLowerCase() === peg$c12) { + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e15); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f14(); + } + s0 = s1; + + return s0; + } + function peg$parsegroupCurrency() { - let s0, s1; + var s0, s1; - if (input.substr(peg$currPos, 13) === peg$c12) { - s0 = peg$c12; + if (input.substr(peg$currPos, 13) === peg$c13) { + s0 = peg$c13; peg$currPos += 13; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e15); } + if (peg$silentFails === 0) { peg$fail(peg$e16); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c13) { - peg$currPos += (14); + if (s1.toLowerCase() === peg$c14) { + peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e16); } + if (peg$silentFails === 0) { peg$fail(peg$e17); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f14(); + s1 = peg$f15(); } s0 = s1; } @@ -1317,19 +1437,19 @@ function peg$parse(input, options) { } function peg$parsetag() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c14) { - peg$currPos += (3); + if (s1.toLowerCase() === peg$c15) { + peg$currPos += 3; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e17); } + if (peg$silentFails === 0) { peg$fail(peg$e18); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f15(); + s1 = peg$f16(); } s0 = s1; @@ -1337,19 +1457,19 @@ function peg$parse(input, options) { } function peg$parsecategory() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c15) { - peg$currPos += (8); + if (s1.toLowerCase() === peg$c16) { + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e18); } + if (peg$silentFails === 0) { peg$fail(peg$e19); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f16(); + s1 = peg$f17(); } s0 = s1; @@ -1357,19 +1477,19 @@ function peg$parse(input, options) { } function peg$parseto() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); - if (s1.toLowerCase() === peg$c16) { - peg$currPos += (2); + if (s1.toLowerCase() === peg$c17) { + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } + if (peg$silentFails === 0) { peg$fail(peg$e20); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f17(); + s1 = peg$f18(); } s0 = s1; @@ -1377,19 +1497,19 @@ function peg$parse(input, options) { } function peg$parseexporter() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c17) { - peg$currPos += (8); + if (s1.toLowerCase() === peg$c18) { + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f18(); + s1 = peg$f19(); } s0 = s1; @@ -1397,19 +1517,19 @@ function peg$parse(input, options) { } function peg$parsepayer() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c18) { - peg$currPos += (5); + if (s1.toLowerCase() === peg$c19) { + peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f19(); + s1 = peg$f20(); } s0 = s1; @@ -1417,27 +1537,27 @@ function peg$parse(input, options) { } function peg$parsetaxRate() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 7); - if (s0.toLowerCase() === peg$c19) { - peg$currPos += (7); + if (s0.toLowerCase() === peg$c20) { + peg$currPos += 7; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e23); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c20) { - peg$currPos += (8); + if (s1.toLowerCase() === peg$c21) { + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f20(); + s1 = peg$f21(); } s0 = s1; } @@ -1446,27 +1566,27 @@ function peg$parse(input, options) { } function peg$parsecardID() { - let s0, s1; + var s0, s1; - if (input.substr(peg$currPos, 6) === peg$c21) { - s0 = peg$c21; + if (input.substr(peg$currPos, 6) === peg$c22) { + s0 = peg$c22; peg$currPos += 6; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } + if (peg$silentFails === 0) { peg$fail(peg$e25); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c22) { - peg$currPos += (4); + if (s1.toLowerCase() === peg$c23) { + peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e25); } + if (peg$silentFails === 0) { peg$fail(peg$e26); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f21(); + s1 = peg$f22(); } s0 = s1; } @@ -1475,19 +1595,19 @@ function peg$parse(input, options) { } function peg$parsefrom() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c23) { - peg$currPos += (4); + if (s1.toLowerCase() === peg$c24) { + peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f22(); + s1 = peg$f23(); } s0 = s1; @@ -1495,19 +1615,19 @@ function peg$parse(input, options) { } function peg$parseattendee() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c24) { - peg$currPos += (8); + if (s1.toLowerCase() === peg$c25) { + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e27); } + if (peg$silentFails === 0) { peg$fail(peg$e28); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f23(); + s1 = peg$f24(); } s0 = s1; @@ -1515,27 +1635,27 @@ function peg$parse(input, options) { } function peg$parseexpenseType() { - let s0, s1; + var s0, s1; - if (input.substr(peg$currPos, 11) === peg$c25) { - s0 = peg$c25; + if (input.substr(peg$currPos, 11) === peg$c26) { + s0 = peg$c26; peg$currPos += 11; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e28); } + if (peg$silentFails === 0) { peg$fail(peg$e29); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c26) { - peg$currPos += (12); + if (s1.toLowerCase() === peg$c27) { + peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e29); } + if (peg$silentFails === 0) { peg$fail(peg$e30); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f24(); + s1 = peg$f25(); } s0 = s1; } @@ -1544,27 +1664,27 @@ function peg$parse(input, options) { } function peg$parsewithdrawalType() { - let s0, s1; + var s0, s1; - if (input.substr(peg$currPos, 14) === peg$c27) { - s0 = peg$c27; + if (input.substr(peg$currPos, 14) === peg$c28) { + s0 = peg$c28; peg$currPos += 14; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e30); } + if (peg$silentFails === 0) { peg$fail(peg$e31); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c28) { - peg$currPos += (15); + if (s1.toLowerCase() === peg$c29) { + peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e31); } + if (peg$silentFails === 0) { peg$fail(peg$e32); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f25(); + s1 = peg$f26(); } s0 = s1; } @@ -1573,27 +1693,27 @@ function peg$parse(input, options) { } function peg$parsewithdrawalID() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 12); - if (s0.toLowerCase() === peg$c29) { - peg$currPos += (12); + if (s0.toLowerCase() === peg$c30) { + peg$currPos += 12; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e32); } + if (peg$silentFails === 0) { peg$fail(peg$e33); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); - if (s1.toLowerCase() === peg$c30) { - peg$currPos += (13); + if (s1.toLowerCase() === peg$c31) { + peg$currPos += 13; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e33); } + if (peg$silentFails === 0) { peg$fail(peg$e34); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f26(); + s1 = peg$f27(); } s0 = s1; } @@ -1602,19 +1722,19 @@ function peg$parse(input, options) { } function peg$parsebillable() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c31) { - peg$currPos += (8); + if (s1.toLowerCase() === peg$c32) { + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e34); } + if (peg$silentFails === 0) { peg$fail(peg$e35); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f27(); + s1 = peg$f28(); } s0 = s1; @@ -1622,19 +1742,19 @@ function peg$parse(input, options) { } function peg$parsereimbursable() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c32) { - peg$currPos += (12); + if (s1.toLowerCase() === peg$c33) { + peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e35); } + if (peg$silentFails === 0) { peg$fail(peg$e36); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f28(); + s1 = peg$f29(); } s0 = s1; @@ -1642,19 +1762,19 @@ function peg$parse(input, options) { } function peg$parsetype() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c33) { - peg$currPos += (4); + if (s1.toLowerCase() === peg$c34) { + peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e36); } + if (peg$silentFails === 0) { peg$fail(peg$e37); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f29(); + s1 = peg$f30(); } s0 = s1; @@ -1662,47 +1782,105 @@ function peg$parse(input, options) { } function peg$parsestatus() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c34) { - peg$currPos += (6); + if (s1.toLowerCase() === peg$c35) { + peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e37); } + if (peg$silentFails === 0) { peg$fail(peg$e38); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f30(); + s1 = peg$f31(); } s0 = s1; return s0; } + function peg$parsesortBy() { + var s0, s1; + + if (input.substr(peg$currPos, 6) === peg$c36) { + s0 = peg$c36; + peg$currPos += 6; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e39); } + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 7); + if (s1.toLowerCase() === peg$c37) { + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e40); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f32(); + } + s0 = s1; + } + + return s0; + } + + function peg$parsesortOrder() { + var s0, s1; + + if (input.substr(peg$currPos, 9) === peg$c38) { + s0 = peg$c38; + peg$currPos += 9; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e41); } + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 10); + if (s1.toLowerCase() === peg$c39) { + peg$currPos += 10; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e42); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f33(); + } + s0 = s1; + } + + return s0; + } + function peg$parsepolicyID() { - let s0, s1; + var s0, s1; - if (input.substr(peg$currPos, 8) === peg$c35) { - s0 = peg$c35; + if (input.substr(peg$currPos, 8) === peg$c40) { + s0 = peg$c40; peg$currPos += 8; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e38); } + if (peg$silentFails === 0) { peg$fail(peg$e43); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c36) { - peg$currPos += (9); + if (s1.toLowerCase() === peg$c41) { + peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e39); } + if (peg$silentFails === 0) { peg$fail(peg$e44); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f31(); + s1 = peg$f34(); } s0 = s1; } @@ -1711,19 +1889,19 @@ function peg$parse(input, options) { } function peg$parsesubmitted() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c37) { - peg$currPos += (9); + if (s1.toLowerCase() === peg$c42) { + peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e40); } + if (peg$silentFails === 0) { peg$fail(peg$e45); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f32(); + s1 = peg$f35(); } s0 = s1; @@ -1731,19 +1909,19 @@ function peg$parse(input, options) { } function peg$parseapproved() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c38) { - peg$currPos += (8); + if (s1.toLowerCase() === peg$c43) { + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e41); } + if (peg$silentFails === 0) { peg$fail(peg$e46); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f33(); + s1 = peg$f36(); } s0 = s1; @@ -1751,19 +1929,19 @@ function peg$parse(input, options) { } function peg$parsepaid() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c39) { - peg$currPos += (4); + if (s1.toLowerCase() === peg$c44) { + peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e42); } + if (peg$silentFails === 0) { peg$fail(peg$e47); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f34(); + s1 = peg$f37(); } s0 = s1; @@ -1771,19 +1949,19 @@ function peg$parse(input, options) { } function peg$parseexported() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c40) { - peg$currPos += (8); + if (s1.toLowerCase() === peg$c45) { + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e43); } + if (peg$silentFails === 0) { peg$fail(peg$e48); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f35(); + s1 = peg$f38(); } s0 = s1; @@ -1791,19 +1969,19 @@ function peg$parse(input, options) { } function peg$parseposted() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c41) { - peg$currPos += (6); + if (s1.toLowerCase() === peg$c46) { + peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e44); } + if (peg$silentFails === 0) { peg$fail(peg$e49); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f36(); + s1 = peg$f39(); } s0 = s1; @@ -1811,19 +1989,19 @@ function peg$parse(input, options) { } function peg$parsewithdrawn() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); - if (s1.toLowerCase() === peg$c42) { - peg$currPos += (9); + if (s1.toLowerCase() === peg$c47) { + peg$currPos += 9; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e45); } + if (peg$silentFails === 0) { peg$fail(peg$e50); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f37(); + s1 = peg$f40(); } s0 = s1; @@ -1831,27 +2009,27 @@ function peg$parse(input, options) { } function peg$parsegroupBy() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 7); - if (s0.toLowerCase() === peg$c43) { - peg$currPos += (7); + if (s0.toLowerCase() === peg$c48) { + peg$currPos += 7; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e46); } + if (peg$silentFails === 0) { peg$fail(peg$e51); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c44) { - peg$currPos += (8); + if (s1.toLowerCase() === peg$c49) { + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e47); } + if (peg$silentFails === 0) { peg$fail(peg$e52); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f38(); + s1 = peg$f41(); } s0 = s1; } @@ -1860,19 +2038,19 @@ function peg$parse(input, options) { } function peg$parselimit() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c45) { - peg$currPos += (5); + if (s1.toLowerCase() === peg$c50) { + peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e48); } + if (peg$silentFails === 0) { peg$fail(peg$e53); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f39(); + s1 = peg$f42(); } s0 = s1; @@ -1880,19 +2058,19 @@ function peg$parse(input, options) { } function peg$parsefeed() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c46) { - peg$currPos += (4); + if (s1.toLowerCase() === peg$c51) { + peg$currPos += 4; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e49); } + if (peg$silentFails === 0) { peg$fail(peg$e54); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f40(); + s1 = peg$f43(); } s0 = s1; @@ -1900,19 +2078,19 @@ function peg$parse(input, options) { } function peg$parsetitle() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c47) { - peg$currPos += (5); + if (s1.toLowerCase() === peg$c52) { + peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e50); } + if (peg$silentFails === 0) { peg$fail(peg$e55); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f41(); + s1 = peg$f44(); } s0 = s1; @@ -1920,19 +2098,19 @@ function peg$parse(input, options) { } function peg$parseassignee() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); - if (s1.toLowerCase() === peg$c48) { - peg$currPos += (8); + if (s1.toLowerCase() === peg$c53) { + peg$currPos += 8; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e51); } + if (peg$silentFails === 0) { peg$fail(peg$e56); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f42(); + s1 = peg$f45(); } s0 = s1; @@ -1940,27 +2118,27 @@ function peg$parse(input, options) { } function peg$parsecreatedBy() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 9); - if (s0.toLowerCase() === peg$c49) { - peg$currPos += (9); + if (s0.toLowerCase() === peg$c54) { + peg$currPos += 9; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e52); } + if (peg$silentFails === 0) { peg$fail(peg$e57); } } if (s0 === peg$FAILED) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c50) { - peg$currPos += (10); + if (s1.toLowerCase() === peg$c55) { + peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e53); } + if (peg$silentFails === 0) { peg$fail(peg$e58); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f43(); + s1 = peg$f46(); } s0 = s1; } @@ -1969,19 +2147,19 @@ function peg$parse(input, options) { } function peg$parseaction() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); - if (s1.toLowerCase() === peg$c51) { - peg$currPos += (6); + if (s1.toLowerCase() === peg$c56) { + peg$currPos += 6; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e54); } + if (peg$silentFails === 0) { peg$fail(peg$e59); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f44(); + s1 = peg$f47(); } s0 = s1; @@ -1989,204 +2167,1401 @@ function peg$parse(input, options) { } function peg$parsetotal() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); - if (s1.toLowerCase() === peg$c52) { - peg$currPos += (5); + if (s1.toLowerCase() === peg$c57) { + peg$currPos += 5; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e55); } + if (peg$silentFails === 0) { peg$fail(peg$e60); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f45(); + s1 = peg$f48(); + } + s0 = s1; + + return s0; + } + + function peg$parsehas() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 3); + if (s1.toLowerCase() === peg$c58) { + peg$currPos += 3; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e61); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f49(); + } + s0 = s1; + + return s0; + } + + function peg$parseis() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 2); + if (s1.toLowerCase() === peg$c59) { + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e62); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f50(); + } + s0 = s1; + + return s0; + } + + function peg$parsepurchaseAmount() { + var s0, s1; + + s0 = input.substr(peg$currPos, 14); + if (s0.toLowerCase() === peg$c60) { + peg$currPos += 14; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e63); } + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 15); + if (s1.toLowerCase() === peg$c61) { + peg$currPos += 15; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e64); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f51(); + } + s0 = s1; + } + + return s0; + } + + function peg$parsepurchaseCurrency() { + var s0, s1; + + s0 = input.substr(peg$currPos, 16); + if (s0.toLowerCase() === peg$c62) { + peg$currPos += 16; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e65); } + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 17); + if (s1.toLowerCase() === peg$c63) { + peg$currPos += 17; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e66); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f52(); + } + s0 = s1; + } + + return s0; + } + + function peg$parsecolumns() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 7); + if (s1.toLowerCase() === peg$c64) { + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e67); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f53(); + } + s0 = s1; + + return s0; + } + + function peg$parseview() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 4); + if (s1.toLowerCase() === peg$c65) { + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e68); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f54(); + } + s0 = s1; + + return s0; + } + + function peg$parsevalues() { + var s0, s1, s2; + + s0 = peg$currPos; + peg$savedPos = peg$currPos; + s1 = peg$f55(); + if (s1) { + s1 = undefined; + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s2 = peg$parsecolumnsValues(); + if (s2 !== peg$FAILED) { + s1 = [s1, s2]; + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$parseperDiem(); + if (s0 === peg$FAILED) { + s0 = peg$parsedraft(); + } + } + + return s0; + } + + function peg$parsecolumnsValues() { + var s0; + + s0 = peg$parseoriginalAmount(); + if (s0 === peg$FAILED) { + s0 = peg$parsetaxRateValue(); + if (s0 === peg$FAILED) { + s0 = peg$parsetaxAmount(); + if (s0 === peg$FAILED) { + s0 = peg$parsepolicyName(); + if (s0 === peg$FAILED) { + s0 = peg$parsewithdrawalId(); + if (s0 === peg$FAILED) { + s0 = peg$parsebankAccount(); + if (s0 === peg$FAILED) { + s0 = peg$parsereportId(); + if (s0 === peg$FAILED) { + s0 = peg$parsebase62ReportId(); + if (s0 === peg$FAILED) { + s0 = peg$parseexportedToColumnValue(); + if (s0 === peg$FAILED) { + s0 = peg$parseexchangeRate(); + if (s0 === peg$FAILED) { + s0 = peg$parsereimbursableTotal(); + if (s0 === peg$FAILED) { + s0 = peg$parsenonReimbursableTotal(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupFrom(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupExpenses(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupTotal(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupCard(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupFeed(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupBankAccount(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupWithdrawn(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupWithdrawalId(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupCategory(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupTag(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupMerchant(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupMonth(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupWeek(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupYear(); + if (s0 === peg$FAILED) { + s0 = peg$parsegroupQuarter(); + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + return s0; + } + + function peg$parseperDiem() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 8); + if (s1.toLowerCase() === peg$c66) { + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e69); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f56(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsedraft() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 6); + if (s1.toLowerCase() === peg$c67) { + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e70); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 5); + if (s1.toLowerCase() === peg$c68) { + peg$currPos += 5; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e71); } + } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f57(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseoriginalAmount() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 15); + if (s1.toLowerCase() === peg$c69) { + peg$currPos += 15; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e72); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f58(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsetaxAmount() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 3); + if (s1.toLowerCase() === peg$c70) { + peg$currPos += 3; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e73); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f59(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsetaxRateValue() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 8); + if (s1.toLowerCase() === peg$c21) { + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f60(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsepolicyName() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c71) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e74); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f61(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsewithdrawalId() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 13); + if (s1.toLowerCase() === peg$c31) { + peg$currPos += 13; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e34); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f62(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsebankAccount() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c72) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e75); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f63(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsereportId() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 14); + if (s1.toLowerCase() === peg$c73) { + peg$currPos += 14; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e76); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f64(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsebase62ReportId() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 9); + if (s1.toLowerCase() === peg$c9) { + peg$currPos += 9; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f65(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseexportedToColumnValue() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c74) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e77); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f66(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseexportedToKeyEnd() { + var s0, s1, s2; + + s0 = peg$parsewordBoundary(); + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = input.charAt(peg$currPos); + if (peg$r1.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e78); } + } + if (s2 !== peg$FAILED) { + s1 = [s1, s2]; + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + + return s0; + } + + function peg$parseexportedTo() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 10); + if (s1.toLowerCase() === peg$c75) { + peg$currPos += 10; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e79); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parseexportedToKeyEnd(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f67(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c74) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e77); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parseexportedToKeyEnd(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f68(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + + return s0; + } + + function peg$parseexchangeRate() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 13); + if (s1.toLowerCase() === peg$c76) { + peg$currPos += 13; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e80); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f69(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsereimbursableTotal() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 18); + if (s1.toLowerCase() === peg$c77) { + peg$currPos += 18; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e81); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f70(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsenonReimbursableTotal() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 22); + if (s1.toLowerCase() === peg$c78) { + peg$currPos += 22; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e82); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f71(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsegroupFrom() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 10); + if (s1.toLowerCase() === peg$c79) { + peg$currPos += 10; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e83); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f72(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsegroupExpenses() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 14); + if (s1.toLowerCase() === peg$c80) { + peg$currPos += 14; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e84); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f73(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsegroupTotal() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c81) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e85); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f74(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsegroupCard() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 10); + if (s1.toLowerCase() === peg$c82) { + peg$currPos += 10; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e86); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f75(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsegroupFeed() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 10); + if (s1.toLowerCase() === peg$c83) { + peg$currPos += 10; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e87); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f76(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsegroupBankAccount() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 18); + if (s1.toLowerCase() === peg$c84) { + peg$currPos += 18; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e88); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f77(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsegroupWithdrawn() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 15); + if (s1.toLowerCase() === peg$c85) { + peg$currPos += 15; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e89); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f78(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; } - s0 = s1; return s0; } - function peg$parsehas() { - let s0, s1; + function peg$parsegroupWithdrawalId() { + var s0, s1, s2, s3; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 3); - if (s1.toLowerCase() === peg$c53) { - peg$currPos += (3); + s1 = input.substr(peg$currPos, 19); + if (s1.toLowerCase() === peg$c86) { + peg$currPos += 19; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e56); } + if (peg$silentFails === 0) { peg$fail(peg$e90); } } if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f46(); + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f79(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; } - s0 = s1; return s0; } - function peg$parseis() { - let s0, s1; + function peg$parsegroupCategory() { + var s0, s1, s2, s3; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 2); - if (s1.toLowerCase() === peg$c54) { - peg$currPos += (2); + s1 = input.substr(peg$currPos, 14); + if (s1.toLowerCase() === peg$c87) { + peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e57); } + if (peg$silentFails === 0) { peg$fail(peg$e91); } } if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f47(); + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f80(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; } - s0 = s1; return s0; } - function peg$parsepurchaseAmount() { - let s0, s1; + function peg$parsegroupTag() { + var s0, s1, s2, s3; - s0 = input.substr(peg$currPos, 14); - if (s0.toLowerCase() === peg$c55) { - peg$currPos += (14); + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 9); + if (s1.toLowerCase() === peg$c88) { + peg$currPos += 9; } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e58); } + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e92); } } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c56) { - peg$currPos += (15); + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e59); } + s2 = peg$FAILED; } - if (s1 !== peg$FAILED) { + if (s2 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f48(); + s0 = peg$f81(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; } - s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; } return s0; } - function peg$parsepurchaseCurrency() { - let s0, s1; + function peg$parsegroupMerchant() { + var s0, s1, s2, s3; - s0 = input.substr(peg$currPos, 16); - if (s0.toLowerCase() === peg$c57) { - peg$currPos += (16); + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 14); + if (s1.toLowerCase() === peg$c89) { + peg$currPos += 14; } else { - s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e60); } + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e93); } } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 17); - if (s1.toLowerCase() === peg$c58) { - peg$currPos += (17); + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e61); } + s2 = peg$FAILED; } - if (s1 !== peg$FAILED) { + if (s2 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f49(); + s0 = peg$f82(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; } - s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; } return s0; } - function peg$parsecolumns() { - let s0, s1; + function peg$parsegroupMonth() { + var s0, s1, s2, s3; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 7); - if (s1.toLowerCase() === peg$c59) { - peg$currPos += (7); + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c90) { + peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e62); } + if (peg$silentFails === 0) { peg$fail(peg$e94); } } if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f50(); + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f83(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; } - s0 = s1; return s0; } - function peg$parseview() { - let s0, s1; + function peg$parsegroupWeek() { + var s0, s1, s2, s3; s0 = peg$currPos; - s1 = input.substr(peg$currPos, 4); - if (s1.toLowerCase() === peg$c60) { - peg$currPos += (4); + s1 = input.substr(peg$currPos, 10); + if (s1.toLowerCase() === peg$c91) { + peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e63); } + if (peg$silentFails === 0) { peg$fail(peg$e95); } } if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f51(); - } - s0 = s1; - - return s0; - } - - function peg$parseexportedToKeyEnd() { - let s0, s1, s2; - - s0 = peg$parsewordBoundary(); - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = peg$parse_(); - s2 = input.charAt(peg$currPos); - if (peg$r1.test(s2)) { - peg$currPos++; + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e64); } } if (s2 !== peg$FAILED) { - s1 = [s1, s2]; - s0 = s1; + peg$savedPos = s0; + s0 = peg$f84(); } else { peg$currPos = s0; s0 = peg$FAILED; } + } else { + peg$currPos = s0; + s0 = peg$FAILED; } return s0; } - function peg$parseexportedTo() { - let s0, s1, s2, s3; + function peg$parsegroupYear() { + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); - if (s1.toLowerCase() === peg$c61) { - peg$currPos += (10); + if (s1.toLowerCase() === peg$c92) { + peg$currPos += 10; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e65); } + if (peg$silentFails === 0) { peg$fail(peg$e96); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; peg$silentFails++; - s3 = peg$parseexportedToKeyEnd(); + s3 = peg$parsewordBoundary(); peg$silentFails--; if (s3 !== peg$FAILED) { peg$currPos = s2; @@ -2196,7 +3571,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f52(); + s0 = peg$f85(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -2205,57 +3580,62 @@ function peg$parse(input, options) { peg$currPos = s0; s0 = peg$FAILED; } - if (s0 === peg$FAILED) { - s0 = peg$currPos; - s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c62) { - peg$currPos += (11); + + return s0; + } + + function peg$parsegroupQuarter() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 13); + if (s1.toLowerCase() === peg$c93) { + peg$currPos += 13; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e97); } + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + s3 = peg$parsewordBoundary(); + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e66); } + s2 = peg$FAILED; } - if (s1 !== peg$FAILED) { - s2 = peg$currPos; - peg$silentFails++; - s3 = peg$parseexportedToKeyEnd(); - peg$silentFails--; - if (s3 !== peg$FAILED) { - peg$currPos = s2; - s2 = undefined; - } else { - s2 = peg$FAILED; - } - if (s2 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f53(); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f86(); } else { peg$currPos = s0; s0 = peg$FAILED; } + } else { + peg$currPos = s0; + s0 = peg$FAILED; } return s0; } function peg$parseoperator() { - let s0, s1; + var s0, s1; peg$silentFails++; s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c63) { - s1 = peg$c63; + if (input.substr(peg$currPos, 2) === peg$c94) { + s1 = peg$c94; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e68); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f54(); + s1 = peg$f87(); } s0 = s1; if (s0 === peg$FAILED) { @@ -2265,81 +3645,81 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e64); } + if (peg$silentFails === 0) { peg$fail(peg$e78); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f55(); + s1 = peg$f88(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c64) { - s1 = peg$c64; + if (input.substr(peg$currPos, 2) === peg$c95) { + s1 = peg$c95; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e69); } + if (peg$silentFails === 0) { peg$fail(peg$e100); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f56(); + s1 = peg$f89(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c65) { - s1 = peg$c65; + if (input.substr(peg$currPos, 2) === peg$c96) { + s1 = peg$c96; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e70); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f57(); + s1 = peg$f90(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c66; + s1 = peg$c97; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e71); } + if (peg$silentFails === 0) { peg$fail(peg$e102); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f58(); + s1 = peg$f91(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c67) { - s1 = peg$c67; + if (input.substr(peg$currPos, 2) === peg$c98) { + s1 = peg$c98; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e72); } + if (peg$silentFails === 0) { peg$fail(peg$e103); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f59(); + s1 = peg$f92(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c68; + s1 = peg$c99; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e73); } + if (peg$silentFails === 0) { peg$fail(peg$e104); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f60(); + s1 = peg$f93(); } s0 = s1; } @@ -2351,20 +3731,20 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e67); } + if (peg$silentFails === 0) { peg$fail(peg$e98); } } return s0; } function peg$parsefilterOperator() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f61(s1); + s1 = peg$f94(s1); } s0 = s1; @@ -2372,7 +3752,7 @@ function peg$parse(input, options) { } function peg$parsealphanumeric() { - let s0, s1, s2; + var s0, s1, s2; peg$silentFails++; s0 = peg$currPos; @@ -2382,7 +3762,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e75); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -2392,7 +3772,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e75); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } } } else { @@ -2400,32 +3780,32 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f62(s1); + s1 = peg$f95(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e74); } + if (peg$silentFails === 0) { peg$fail(peg$e105); } } return s0; } function peg$parselogicalAnd() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f63(); + s1 = peg$f96(); s0 = s1; return s0; } function peg$parse_() { - let s0, s1; + var s0, s1; peg$silentFails++; s0 = []; @@ -2434,7 +3814,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e76); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -2443,20 +3823,22 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e76); } + if (peg$silentFails === 0) { peg$fail(peg$e108); } } } peg$silentFails--; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e107); } return s0; } function peg$parsequotedString() { - let s0, s1, s2; + var s0, s1, s2; s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f64(); + s1 = peg$f97(); if (s1) { s1 = undefined; } else { @@ -2483,7 +3865,7 @@ function peg$parse(input, options) { } function peg$parsesimpleQuotedString() { - let s0, s1, s2, s3, s4, s5, s6; + var s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -2493,7 +3875,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -2502,7 +3884,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } } s2 = input.charAt(peg$currPos); @@ -2510,7 +3892,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e79); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } if (s2 !== peg$FAILED) { s3 = []; @@ -2519,7 +3901,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -2528,7 +3910,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } } s4 = input.charAt(peg$currPos); @@ -2536,7 +3918,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e79); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } if (s4 !== peg$FAILED) { s5 = []; @@ -2545,7 +3927,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e75); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -2554,11 +3936,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e75); } + if (peg$silentFails === 0) { peg$fail(peg$e106); } } } peg$savedPos = s0; - s0 = peg$f65(s1, s3, s5); + s0 = peg$f98(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -2570,14 +3952,14 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e77); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } return s0; } function peg$parsenestedQuotedString() { - let s0, s1, s2, s3, s4, s5, s6; + var s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -2587,7 +3969,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -2596,7 +3978,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e78); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } } s2 = input.charAt(peg$currPos); @@ -2604,7 +3986,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e79); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } if (s2 !== peg$FAILED) { s3 = []; @@ -2613,7 +3995,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -2629,15 +4011,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c69; + s6 = peg$c100; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e81); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f66(s1); + s4 = peg$f99(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -2660,15 +4042,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c70; + s6 = peg$c101; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e82); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f67(s1); + s4 = peg$f100(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -2691,15 +4073,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c71; + s6 = peg$c102; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e83); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f68(s1); + s4 = peg$f101(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -2718,7 +4100,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e80); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -2734,15 +4116,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c69; + s6 = peg$c100; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e81); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f66(s1); + s4 = peg$f99(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -2765,15 +4147,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c70; + s6 = peg$c101; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e82); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f67(s1); + s4 = peg$f100(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -2796,15 +4178,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c71; + s6 = peg$c102; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e83); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f68(s1); + s4 = peg$f101(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -2820,7 +4202,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f69(s1, s3, s4); + s0 = peg$f102(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -2832,14 +4214,14 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e77); } + if (peg$silentFails === 0) { peg$fail(peg$e109); } } return s0; } function peg$parseclosingQuote() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.charAt(peg$currPos); @@ -2847,7 +4229,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e79); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -2876,7 +4258,7 @@ function peg$parse(input, options) { } function peg$parsevalidClosingCondition() { - let s0, s1, s2, s3, s4; + var s0, s1, s2, s3, s4; s0 = peg$currPos; s1 = []; @@ -2885,7 +4267,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e84); } + if (peg$silentFails === 0) { peg$fail(peg$e116); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -2894,7 +4276,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e84); } + if (peg$silentFails === 0) { peg$fail(peg$e116); } } } s2 = []; @@ -2903,7 +4285,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e85); } + if (peg$silentFails === 0) { peg$fail(peg$e117); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -2912,7 +4294,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e85); } + if (peg$silentFails === 0) { peg$fail(peg$e117); } } } s3 = []; @@ -2921,7 +4303,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e86); } + if (peg$silentFails === 0) { peg$fail(peg$e118); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -2930,7 +4312,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e86); } + if (peg$silentFails === 0) { peg$fail(peg$e118); } } } s4 = peg$parseoperator(); @@ -2949,7 +4331,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e87); } + if (peg$silentFails === 0) { peg$fail(peg$e119); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -2958,7 +4340,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e87); } + if (peg$silentFails === 0) { peg$fail(peg$e119); } } } s2 = peg$currPos; @@ -3005,7 +4387,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e88); } + if (peg$silentFails === 0) { peg$fail(peg$e120); } } } } @@ -3014,14 +4396,14 @@ function peg$parse(input, options) { } function peg$parsewordBoundary() { - let s0, s1; + var s0, s1; s0 = input.charAt(peg$currPos); if (peg$r12.test(s0)) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e89); } + if (peg$silentFails === 0) { peg$fail(peg$e121); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -3060,35 +4442,29 @@ function peg$parse(input, options) { peg$result = peg$startRuleFunction(); - const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); - function peg$throw() { - if (peg$result !== peg$FAILED && peg$currPos < input.length) { - peg$fail(peg$endExpectation()); - } - - throw peg$buildStructuredError( - peg$maxFailExpected, - peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, - peg$maxFailPos < input.length - ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) - : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) - ); - } if (options.peg$library) { return /** @type {any} */ ({ peg$result, peg$currPos, peg$FAILED, peg$maxFailExpected, - peg$maxFailPos, - peg$success, - peg$throw: peg$success ? undefined : peg$throw, + peg$maxFailPos }); } - if (peg$success) { + if (peg$result !== peg$FAILED && peg$currPos === input.length) { return peg$result; } else { - peg$throw(); + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); } } diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index f38d92cffd86..52712ed2731b 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -1,4 +1,4 @@ -// @generated by Peggy 5.0.6. +// @generated by Peggy 4.0.3. // // https://peggyjs.org/ @@ -12,615 +12,627 @@ return { operator, left, right }; } -class peg$SyntaxError extends SyntaxError { - constructor(message, expected, found, location) { - super(message); - this.expected = expected; - this.found = found; - this.location = location; - this.name = "SyntaxError"; - } - - format(sources) { - let str = "Error: " + this.message; - if (this.location) { - let src = null; - const st = sources.find(s => s.source === this.location.source); - if (st) { - src = st.text.split(/\r\n|\n|\r/g); - } - const s = this.location.start; - const offset_s = (this.location.source && (typeof this.location.source.offset === "function")) - ? this.location.source.offset(s) - : s; - const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; - if (src) { - const e = this.location.end; - const filler = "".padEnd(offset_s.line.toString().length, " "); - const line = src[s.line - 1]; - const last = s.line === e.line ? e.column : line.length + 1; - const hatLen = (last - s.column) || 1; - str += "\n --> " + loc + "\n" - + filler + " |\n" - + offset_s.line + " | " + line + "\n" - + filler + " | " + "".padEnd(s.column - 1, " ") - + "".padEnd(hatLen, "^"); - } else { - str += "\n at " + loc; - } - } - return str; - } - - static buildMessage(expected, found) { - function hex(ch) { - return ch.codePointAt(0).toString(16).toUpperCase(); - } +function peg$subclass(child, parent) { + function C() { this.constructor = child; } + C.prototype = parent.prototype; + child.prototype = new C(); +} - const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode") - ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu") - : null; - function unicodeEscape(s) { - if (nonPrintable) { - return s.replace(nonPrintable, ch => "\\u{" + hex(ch) + "}"); - } - return s; - } +function peg$SyntaxError(message, expected, found, location) { + var self = Error.call(this, message); + // istanbul ignore next Check is a necessary evil to support older environments + if (Object.setPrototypeOf) { + Object.setPrototypeOf(self, peg$SyntaxError.prototype); + } + self.expected = expected; + self.found = found; + self.location = location; + self.name = "SyntaxError"; + return self; +} - function literalEscape(s) { - return unicodeEscape(s - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) - .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); - } +peg$subclass(peg$SyntaxError, Error); - function classEscape(s) { - return unicodeEscape(s - .replace(/\\/g, "\\\\") - .replace(/\]/g, "\\]") - .replace(/\^/g, "\\^") - .replace(/-/g, "\\-") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, ch => "\\x0" + hex(ch)) - .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x" + hex(ch))); - } +function peg$padEnd(str, targetLength, padString) { + padString = padString || " "; + if (str.length > targetLength) { return str; } + targetLength -= str.length; + padString += padString.repeat(targetLength); + return str + padString.slice(0, targetLength); +} - const DESCRIBE_EXPECTATION_FNS = { - literal(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, +peg$SyntaxError.prototype.format = function(sources) { + var str = "Error: " + this.message; + if (this.location) { + var src = null; + var k; + for (k = 0; k < sources.length; k++) { + if (sources[k].source === this.location.source) { + src = sources[k].text.split(/\r\n|\n|\r/g); + break; + } + } + var s = this.location.start; + var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + var e = this.location.end; + var filler = peg$padEnd("", offset_s.line.toString().length, ' '); + var line = src[s.line - 1]; + var last = s.line === e.line ? e.column : line.length + 1; + var hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + peg$padEnd("", s.column - 1, ' ') + + peg$padEnd("", hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; +}; - class(expectation) { - const escapedParts = expectation.parts.map( - part => (Array.isArray(part) - ? classEscape(part[0]) + "-" + classEscape(part[1]) - : classEscape(part)) - ); +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, - return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : ""); - }, + class: function(expectation) { + var escapedParts = expectation.parts.map(function(part) { + return Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part); + }); - any() { - return "any character"; - }, + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; + }, - end() { - return "end of input"; - }, + any: function() { + return "any character"; + }, - other(expectation) { - return expectation.description; - }, - }; + end: function() { + return "end of input"; + }, - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + other: function(expectation) { + return expectation.description; } + }; + + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } - function describeExpected(expected) { - const descriptions = expected.map(describeExpectation); - descriptions.sort(); + function literalEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } - if (descriptions.length > 0) { - let j = 1; - for (let i = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } - } - descriptions.length = j; - } + function classEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } - switch (descriptions.length) { - case 1: - return descriptions[0]; + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + var descriptions = expected.map(describeExpectation); + var i, j; - case 2: - return descriptions[0] + " or " + descriptions[1]; + descriptions.sort(); - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } } + descriptions.length = j; } - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; } + } - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; } -} + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; function peg$parse(input, options) { options = options !== undefined ? options : {}; - const peg$FAILED = {}; - const peg$source = options.grammarSource; - - const peg$startRuleFunctions = { - query: peg$parsequery, - }; - let peg$startRuleFunction = peg$parsequery; - - const peg$c0 = "-"; - const peg$c1 = "report-field"; - const peg$c2 = "reportfield"; - const peg$c3 = ","; - const peg$c4 = "date"; - const peg$c5 = "amount"; - const peg$c6 = "merchant"; - const peg$c7 = "description"; - const peg$c8 = "reportid"; - const peg$c9 = "report-id"; - const peg$c10 = "keyword"; - const peg$c11 = "in"; - const peg$c12 = "currency"; - const peg$c13 = "groupCurrency"; - const peg$c14 = "group-currency"; - const peg$c15 = "tag"; - const peg$c16 = "category"; - const peg$c17 = "to"; - const peg$c18 = "exporter"; - const peg$c19 = "payer"; - const peg$c20 = "taxrate"; - const peg$c21 = "tax-rate"; - const peg$c22 = "cardID"; - const peg$c23 = "card"; - const peg$c24 = "from"; - const peg$c25 = "attendee"; - const peg$c26 = "expenseType"; - const peg$c27 = "expense-type"; - const peg$c28 = "withdrawalType"; - const peg$c29 = "withdrawal-type"; - const peg$c30 = "withdrawalid"; - const peg$c31 = "withdrawal-id"; - const peg$c32 = "billable"; - const peg$c33 = "reimbursable"; - const peg$c34 = "type"; - const peg$c35 = "status"; - const peg$c36 = "sortBy"; - const peg$c37 = "sort-by"; - const peg$c38 = "sortOrder"; - const peg$c39 = "sort-order"; - const peg$c40 = "policyID"; - const peg$c41 = "workspace"; - const peg$c42 = "submitted"; - const peg$c43 = "approved"; - const peg$c44 = "paid"; - const peg$c45 = "exported"; - const peg$c46 = "posted"; - const peg$c47 = "withdrawn"; - const peg$c48 = "groupby"; - const peg$c49 = "group-by"; - const peg$c50 = "limit"; - const peg$c51 = "feed"; - const peg$c52 = "title"; - const peg$c53 = "assignee"; - const peg$c54 = "createdby"; - const peg$c55 = "created-by"; - const peg$c56 = "action"; - const peg$c57 = "total"; - const peg$c58 = "has"; - const peg$c59 = "is"; - const peg$c60 = "purchaseamount"; - const peg$c61 = "purchase-amount"; - const peg$c62 = "purchasecurrency"; - const peg$c63 = "purchase-currency"; - const peg$c64 = "columns"; - const peg$c65 = "view"; - const peg$c66 = "per-diem"; - const peg$c67 = "drafts"; - const peg$c68 = "draft"; - const peg$c69 = "original-amount"; - const peg$c70 = "tax"; - const peg$c71 = "policy-name"; - const peg$c72 = "bank-account"; - const peg$c73 = "long-report-id"; - const peg$c74 = "exported-to"; - const peg$c75 = "exportedto"; - const peg$c76 = "exchange-rate"; - const peg$c77 = "reimbursable-total"; - const peg$c78 = "non-reimbursable-total"; - const peg$c79 = "group-from"; - const peg$c80 = "group-expenses"; - const peg$c81 = "group-total"; - const peg$c82 = "group-card"; - const peg$c83 = "group-feed"; - const peg$c84 = "group-bank-account"; - const peg$c85 = "group-withdrawn"; - const peg$c86 = "group-withdrawal-id"; - const peg$c87 = "group-category"; - const peg$c88 = "group-tag"; - const peg$c89 = "group-merchant"; - const peg$c90 = "group-month"; - const peg$c91 = "group-week"; - const peg$c92 = "group-year"; - const peg$c93 = "group-quarter"; - const peg$c94 = "*:"; - const peg$c95 = "!="; - const peg$c96 = ">="; - const peg$c97 = ">"; - const peg$c98 = "<="; - const peg$c99 = "<"; - const peg$c100 = "\u201C"; - const peg$c101 = "\u201D"; - const peg$c102 = "\""; - - const peg$r0 = /^[^ \t\r\n\xA0]/; - const peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; - const peg$r2 = /^[:=]/; - const peg$r3 = /^[^ ,\t\n\r\xA0]/; - const peg$r4 = /^[ \t\r\n\xA0]/; - const peg$r5 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; - const peg$r6 = /^["\u201C-\u201D]/; - const peg$r7 = /^[^"\u201D\u201C\r\n]/; - const peg$r8 = /^[ \t\n\r\xA0a-zA-Z0-9]/; - const peg$r9 = /^[a-zA-Z0-9]/; - const peg$r10 = /^[ \t\n\r\xA0]/; - const peg$r11 = /^[ \t\n\r\xA0a-zA-Z]/; - const peg$r12 = /^[,]/; - const peg$r13 = /^[ \t\n\r\xA0,]/; - - const peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], true, false, false); - const peg$e1 = peg$literalExpectation("-", false); - const peg$e2 = peg$literalExpectation("report-field", true); - const peg$e3 = peg$literalExpectation("reportfield", true); - const peg$e4 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false, false); - const peg$e5 = peg$anyExpectation(); - const peg$e6 = peg$otherExpectation("key"); - const peg$e7 = peg$otherExpectation("default key"); - const peg$e8 = peg$literalExpectation(",", false); - const peg$e9 = peg$literalExpectation("date", true); - const peg$e10 = peg$literalExpectation("amount", true); - const peg$e11 = peg$literalExpectation("merchant", true); - const peg$e12 = peg$literalExpectation("description", true); - const peg$e13 = peg$literalExpectation("reportid", true); - const peg$e14 = peg$literalExpectation("report-id", true); - const peg$e15 = peg$literalExpectation("keyword", true); - const peg$e16 = peg$literalExpectation("in", true); - const peg$e17 = peg$literalExpectation("currency", true); - const peg$e18 = peg$literalExpectation("groupCurrency", false); - const peg$e19 = peg$literalExpectation("group-currency", true); - const peg$e20 = peg$literalExpectation("tag", true); - const peg$e21 = peg$literalExpectation("category", true); - const peg$e22 = peg$literalExpectation("to", true); - const peg$e23 = peg$literalExpectation("exporter", true); - const peg$e24 = peg$literalExpectation("payer", true); - const peg$e25 = peg$literalExpectation("taxRate", true); - const peg$e26 = peg$literalExpectation("tax-rate", true); - const peg$e27 = peg$literalExpectation("cardID", false); - const peg$e28 = peg$literalExpectation("card", true); - const peg$e29 = peg$literalExpectation("from", true); - const peg$e30 = peg$literalExpectation("attendee", true); - const peg$e31 = peg$literalExpectation("expenseType", false); - const peg$e32 = peg$literalExpectation("expense-type", true); - const peg$e33 = peg$literalExpectation("withdrawalType", false); - const peg$e34 = peg$literalExpectation("withdrawal-type", true); - const peg$e35 = peg$literalExpectation("withdrawalID", true); - const peg$e36 = peg$literalExpectation("withdrawal-id", true); - const peg$e37 = peg$literalExpectation("billable", true); - const peg$e38 = peg$literalExpectation("reimbursable", true); - const peg$e39 = peg$literalExpectation("type", true); - const peg$e40 = peg$literalExpectation("status", true); - const peg$e41 = peg$literalExpectation("sortBy", false); - const peg$e42 = peg$literalExpectation("sort-by", true); - const peg$e43 = peg$literalExpectation("sortOrder", false); - const peg$e44 = peg$literalExpectation("sort-order", true); - const peg$e45 = peg$literalExpectation("policyID", false); - const peg$e46 = peg$literalExpectation("workspace", true); - const peg$e47 = peg$literalExpectation("submitted", true); - const peg$e48 = peg$literalExpectation("approved", true); - const peg$e49 = peg$literalExpectation("paid", true); - const peg$e50 = peg$literalExpectation("exported", true); - const peg$e51 = peg$literalExpectation("posted", true); - const peg$e52 = peg$literalExpectation("withdrawn", true); - const peg$e53 = peg$literalExpectation("groupBy", true); - const peg$e54 = peg$literalExpectation("group-by", true); - const peg$e55 = peg$literalExpectation("limit", true); - const peg$e56 = peg$literalExpectation("feed", true); - const peg$e57 = peg$literalExpectation("title", true); - const peg$e58 = peg$literalExpectation("assignee", true); - const peg$e59 = peg$literalExpectation("createdBy", true); - const peg$e60 = peg$literalExpectation("created-by", true); - const peg$e61 = peg$literalExpectation("action", true); - const peg$e62 = peg$literalExpectation("total", true); - const peg$e63 = peg$literalExpectation("has", true); - const peg$e64 = peg$literalExpectation("is", true); - const peg$e65 = peg$literalExpectation("purchaseAmount", true); - const peg$e66 = peg$literalExpectation("purchase-amount", true); - const peg$e67 = peg$literalExpectation("purchaseCurrency", true); - const peg$e68 = peg$literalExpectation("purchase-currency", true); - const peg$e69 = peg$literalExpectation("columns", true); - const peg$e70 = peg$literalExpectation("view", true); - const peg$e71 = peg$literalExpectation("per-diem", true); - const peg$e72 = peg$literalExpectation("drafts", true); - const peg$e73 = peg$literalExpectation("draft", true); - const peg$e74 = peg$literalExpectation("original-amount", true); - const peg$e75 = peg$literalExpectation("tax", true); - const peg$e76 = peg$literalExpectation("policy-name", true); - const peg$e77 = peg$literalExpectation("bank-account", true); - const peg$e78 = peg$literalExpectation("long-report-id", true); - const peg$e79 = peg$literalExpectation("exported-to", true); - const peg$e80 = peg$classExpectation([":", "="], false, false, false); - const peg$e81 = peg$literalExpectation("exportedTo", true); - const peg$e82 = peg$literalExpectation("exchange-rate", true); - const peg$e83 = peg$literalExpectation("reimbursable-total", true); - const peg$e84 = peg$literalExpectation("non-reimbursable-total", true); - const peg$e85 = peg$literalExpectation("group-from", true); - const peg$e86 = peg$literalExpectation("group-expenses", true); - const peg$e87 = peg$literalExpectation("group-total", true); - const peg$e88 = peg$literalExpectation("group-card", true); - const peg$e89 = peg$literalExpectation("group-feed", true); - const peg$e90 = peg$literalExpectation("group-bank-account", true); - const peg$e91 = peg$literalExpectation("group-withdrawn", true); - const peg$e92 = peg$literalExpectation("group-withdrawal-id", true); - const peg$e93 = peg$literalExpectation("group-category", true); - const peg$e94 = peg$literalExpectation("group-tag", true); - const peg$e95 = peg$literalExpectation("group-merchant", true); - const peg$e96 = peg$literalExpectation("group-month", true); - const peg$e97 = peg$literalExpectation("group-week", true); - const peg$e98 = peg$literalExpectation("group-year", true); - const peg$e99 = peg$literalExpectation("group-quarter", true); - const peg$e100 = peg$otherExpectation("operator"); - const peg$e101 = peg$literalExpectation("*:", false); - const peg$e102 = peg$literalExpectation("!=", false); - const peg$e103 = peg$literalExpectation(">=", false); - const peg$e104 = peg$literalExpectation(">", false); - const peg$e105 = peg$literalExpectation("<=", false); - const peg$e106 = peg$literalExpectation("<", false); - const peg$e107 = peg$otherExpectation("word"); - const peg$e108 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false, false); - const peg$e109 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false, false); - const peg$e110 = peg$otherExpectation("quote"); - const peg$e111 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false, false); - const peg$e112 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false, false); - const peg$e113 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false, false); - const peg$e114 = peg$literalExpectation("\u201C", false); - const peg$e115 = peg$literalExpectation("\u201D", false); - const peg$e116 = peg$literalExpectation("\"", false); - const peg$e117 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); - const peg$e118 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false, false); - const peg$e119 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false, false); - const peg$e120 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false, false); - const peg$e121 = peg$classExpectation([","], false, false, false); - const peg$e122 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false, false); - - function peg$f0(filters) { return applyDefaults(filters); } - function peg$f1(head, tail) { - const allFilters = [head, ...tail.map(([_, filter]) => filter)] - .filter(Boolean) - .filter((filter) => filter.right); - rawFilterList = allFilters.map(createRawFilter).filter(Boolean); - - const filtersWithoutDefaults = allFilters.filter((filter) => !filter.isDefault); - - if (!filtersWithoutDefaults.length) { - return null; - } - - const keywords = filtersWithoutDefaults.filter( - (filter) => - filter.left === "keyword" || filter.right?.left === "keyword" - ); - const nonKeywords = filtersWithoutDefaults.filter( - (filter) => - filter.left !== "keyword" && filter.right?.left !== "keyword" - ); - - const keywordFilter = buildFilter( - "eq", - "keyword", - keywords - .map((filter) => filter.right.replaceAll(/^(['"])(.*)\1$/g, "$2")) - .flat() - ); - if (keywordFilter.right.length > 0) { - nonKeywords.push(keywordFilter); - } - return nonKeywords.reduce((result, filter) => - buildFilter("and", result, filter) - ); - } - function peg$f2(key, op, value) { - updateDefaultValues(key, value); - return {...buildFilter(op, key, value), isDefault: true}; - } - function peg$f3(value) { - //handle no-breaking space - let word; - if (Array.isArray(value)) { - word = value.join(""); - } else { - word = value; - } - return buildFilter("eq", "keyword", word); - } - function peg$f4(neg, field, op, values) { - expectingNestedQuote = false; nameOperator = false; + var peg$FAILED = {}; + var peg$source = options.grammarSource; + + var peg$startRuleFunctions = { query: peg$parsequery }; + var peg$startRuleFunction = peg$parsequery; + + var peg$c0 = "-"; + var peg$c1 = "report-field"; + var peg$c2 = "reportfield"; + var peg$c3 = ","; + var peg$c4 = "date"; + var peg$c5 = "amount"; + var peg$c6 = "merchant"; + var peg$c7 = "description"; + var peg$c8 = "reportid"; + var peg$c9 = "report-id"; + var peg$c10 = "keyword"; + var peg$c11 = "in"; + var peg$c12 = "currency"; + var peg$c13 = "groupCurrency"; + var peg$c14 = "group-currency"; + var peg$c15 = "tag"; + var peg$c16 = "category"; + var peg$c17 = "to"; + var peg$c18 = "exporter"; + var peg$c19 = "payer"; + var peg$c20 = "taxrate"; + var peg$c21 = "tax-rate"; + var peg$c22 = "cardID"; + var peg$c23 = "card"; + var peg$c24 = "from"; + var peg$c25 = "attendee"; + var peg$c26 = "expenseType"; + var peg$c27 = "expense-type"; + var peg$c28 = "withdrawalType"; + var peg$c29 = "withdrawal-type"; + var peg$c30 = "withdrawalid"; + var peg$c31 = "withdrawal-id"; + var peg$c32 = "billable"; + var peg$c33 = "reimbursable"; + var peg$c34 = "type"; + var peg$c35 = "status"; + var peg$c36 = "sortBy"; + var peg$c37 = "sort-by"; + var peg$c38 = "sortOrder"; + var peg$c39 = "sort-order"; + var peg$c40 = "policyID"; + var peg$c41 = "workspace"; + var peg$c42 = "submitted"; + var peg$c43 = "approved"; + var peg$c44 = "paid"; + var peg$c45 = "exported"; + var peg$c46 = "posted"; + var peg$c47 = "withdrawn"; + var peg$c48 = "groupby"; + var peg$c49 = "group-by"; + var peg$c50 = "limit"; + var peg$c51 = "feed"; + var peg$c52 = "title"; + var peg$c53 = "assignee"; + var peg$c54 = "createdby"; + var peg$c55 = "created-by"; + var peg$c56 = "action"; + var peg$c57 = "total"; + var peg$c58 = "has"; + var peg$c59 = "is"; + var peg$c60 = "purchaseamount"; + var peg$c61 = "purchase-amount"; + var peg$c62 = "purchasecurrency"; + var peg$c63 = "purchase-currency"; + var peg$c64 = "columns"; + var peg$c65 = "view"; + var peg$c66 = "per-diem"; + var peg$c67 = "drafts"; + var peg$c68 = "draft"; + var peg$c69 = "original-amount"; + var peg$c70 = "tax"; + var peg$c71 = "policy-name"; + var peg$c72 = "bank-account"; + var peg$c73 = "long-report-id"; + var peg$c74 = "exported-to"; + var peg$c75 = "exportedto"; + var peg$c76 = "exchange-rate"; + var peg$c77 = "reimbursable-total"; + var peg$c78 = "non-reimbursable-total"; + var peg$c79 = "group-from"; + var peg$c80 = "group-expenses"; + var peg$c81 = "group-total"; + var peg$c82 = "group-card"; + var peg$c83 = "group-feed"; + var peg$c84 = "group-bank-account"; + var peg$c85 = "group-withdrawn"; + var peg$c86 = "group-withdrawal-id"; + var peg$c87 = "group-category"; + var peg$c88 = "group-tag"; + var peg$c89 = "group-merchant"; + var peg$c90 = "group-month"; + var peg$c91 = "group-week"; + var peg$c92 = "group-year"; + var peg$c93 = "group-quarter"; + var peg$c94 = "*:"; + var peg$c95 = "!="; + var peg$c96 = ">="; + var peg$c97 = ">"; + var peg$c98 = "<="; + var peg$c99 = "<"; + var peg$c100 = "\u201C"; + var peg$c101 = "\u201D"; + var peg$c102 = "\""; + + var peg$r0 = /^[^ \t\r\n\xA0]/; + var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; + var peg$r2 = /^[:=]/; + var peg$r3 = /^[^ ,\t\n\r\xA0]/; + var peg$r4 = /^[ \t\r\n\xA0]/; + var peg$r5 = /^[^ ,"\u201D\u201C\t\n\r\xA0]/; + var peg$r6 = /^["\u201C-\u201D]/; + var peg$r7 = /^[^"\u201D\u201C\r\n]/; + var peg$r8 = /^[ \t\n\r\xA0a-zA-Z0-9]/; + var peg$r9 = /^[a-zA-Z0-9]/; + var peg$r10 = /^[ \t\n\r\xA0]/; + var peg$r11 = /^[ \t\n\r\xA0a-zA-Z]/; + var peg$r12 = /^[,]/; + var peg$r13 = /^[ \t\n\r\xA0,]/; + + var peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], true, false); + var peg$e1 = peg$literalExpectation("-", false); + var peg$e2 = peg$literalExpectation("report-field", true); + var peg$e3 = peg$literalExpectation("reportfield", true); + var peg$e4 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0", ",", ":", "=", "<", ">", "!"], false, false); + var peg$e5 = peg$anyExpectation(); + var peg$e6 = peg$otherExpectation("key"); + var peg$e7 = peg$otherExpectation("default key"); + var peg$e8 = peg$literalExpectation(",", false); + var peg$e9 = peg$literalExpectation("date", true); + var peg$e10 = peg$literalExpectation("amount", true); + var peg$e11 = peg$literalExpectation("merchant", true); + var peg$e12 = peg$literalExpectation("description", true); + var peg$e13 = peg$literalExpectation("reportid", true); + var peg$e14 = peg$literalExpectation("report-id", true); + var peg$e15 = peg$literalExpectation("keyword", true); + var peg$e16 = peg$literalExpectation("in", true); + var peg$e17 = peg$literalExpectation("currency", true); + var peg$e18 = peg$literalExpectation("groupCurrency", false); + var peg$e19 = peg$literalExpectation("group-currency", true); + var peg$e20 = peg$literalExpectation("tag", true); + var peg$e21 = peg$literalExpectation("category", true); + var peg$e22 = peg$literalExpectation("to", true); + var peg$e23 = peg$literalExpectation("exporter", true); + var peg$e24 = peg$literalExpectation("payer", true); + var peg$e25 = peg$literalExpectation("taxRate", true); + var peg$e26 = peg$literalExpectation("tax-rate", true); + var peg$e27 = peg$literalExpectation("cardID", false); + var peg$e28 = peg$literalExpectation("card", true); + var peg$e29 = peg$literalExpectation("from", true); + var peg$e30 = peg$literalExpectation("attendee", true); + var peg$e31 = peg$literalExpectation("expenseType", false); + var peg$e32 = peg$literalExpectation("expense-type", true); + var peg$e33 = peg$literalExpectation("withdrawalType", false); + var peg$e34 = peg$literalExpectation("withdrawal-type", true); + var peg$e35 = peg$literalExpectation("withdrawalID", true); + var peg$e36 = peg$literalExpectation("withdrawal-id", true); + var peg$e37 = peg$literalExpectation("billable", true); + var peg$e38 = peg$literalExpectation("reimbursable", true); + var peg$e39 = peg$literalExpectation("type", true); + var peg$e40 = peg$literalExpectation("status", true); + var peg$e41 = peg$literalExpectation("sortBy", false); + var peg$e42 = peg$literalExpectation("sort-by", true); + var peg$e43 = peg$literalExpectation("sortOrder", false); + var peg$e44 = peg$literalExpectation("sort-order", true); + var peg$e45 = peg$literalExpectation("policyID", false); + var peg$e46 = peg$literalExpectation("workspace", true); + var peg$e47 = peg$literalExpectation("submitted", true); + var peg$e48 = peg$literalExpectation("approved", true); + var peg$e49 = peg$literalExpectation("paid", true); + var peg$e50 = peg$literalExpectation("exported", true); + var peg$e51 = peg$literalExpectation("posted", true); + var peg$e52 = peg$literalExpectation("withdrawn", true); + var peg$e53 = peg$literalExpectation("groupBy", true); + var peg$e54 = peg$literalExpectation("group-by", true); + var peg$e55 = peg$literalExpectation("limit", true); + var peg$e56 = peg$literalExpectation("feed", true); + var peg$e57 = peg$literalExpectation("title", true); + var peg$e58 = peg$literalExpectation("assignee", true); + var peg$e59 = peg$literalExpectation("createdBy", true); + var peg$e60 = peg$literalExpectation("created-by", true); + var peg$e61 = peg$literalExpectation("action", true); + var peg$e62 = peg$literalExpectation("total", true); + var peg$e63 = peg$literalExpectation("has", true); + var peg$e64 = peg$literalExpectation("is", true); + var peg$e65 = peg$literalExpectation("purchaseAmount", true); + var peg$e66 = peg$literalExpectation("purchase-amount", true); + var peg$e67 = peg$literalExpectation("purchaseCurrency", true); + var peg$e68 = peg$literalExpectation("purchase-currency", true); + var peg$e69 = peg$literalExpectation("columns", true); + var peg$e70 = peg$literalExpectation("view", true); + var peg$e71 = peg$literalExpectation("per-diem", true); + var peg$e72 = peg$literalExpectation("drafts", true); + var peg$e73 = peg$literalExpectation("draft", true); + var peg$e74 = peg$literalExpectation("original-amount", true); + var peg$e75 = peg$literalExpectation("tax", true); + var peg$e76 = peg$literalExpectation("policy-name", true); + var peg$e77 = peg$literalExpectation("bank-account", true); + var peg$e78 = peg$literalExpectation("long-report-id", true); + var peg$e79 = peg$literalExpectation("exported-to", true); + var peg$e80 = peg$classExpectation([":", "="], false, false); + var peg$e81 = peg$literalExpectation("exportedTo", true); + var peg$e82 = peg$literalExpectation("exchange-rate", true); + var peg$e83 = peg$literalExpectation("reimbursable-total", true); + var peg$e84 = peg$literalExpectation("non-reimbursable-total", true); + var peg$e85 = peg$literalExpectation("group-from", true); + var peg$e86 = peg$literalExpectation("group-expenses", true); + var peg$e87 = peg$literalExpectation("group-total", true); + var peg$e88 = peg$literalExpectation("group-card", true); + var peg$e89 = peg$literalExpectation("group-feed", true); + var peg$e90 = peg$literalExpectation("group-bank-account", true); + var peg$e91 = peg$literalExpectation("group-withdrawn", true); + var peg$e92 = peg$literalExpectation("group-withdrawal-id", true); + var peg$e93 = peg$literalExpectation("group-category", true); + var peg$e94 = peg$literalExpectation("group-tag", true); + var peg$e95 = peg$literalExpectation("group-merchant", true); + var peg$e96 = peg$literalExpectation("group-month", true); + var peg$e97 = peg$literalExpectation("group-week", true); + var peg$e98 = peg$literalExpectation("group-year", true); + var peg$e99 = peg$literalExpectation("group-quarter", true); + var peg$e100 = peg$otherExpectation("operator"); + var peg$e101 = peg$literalExpectation("*:", false); + var peg$e102 = peg$literalExpectation("!=", false); + var peg$e103 = peg$literalExpectation(">=", false); + var peg$e104 = peg$literalExpectation(">", false); + var peg$e105 = peg$literalExpectation("<=", false); + var peg$e106 = peg$literalExpectation("<", false); + var peg$e107 = peg$otherExpectation("word"); + var peg$e108 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e109 = peg$otherExpectation("whitespace"); + var peg$e110 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e111 = peg$otherExpectation("quote"); + var peg$e112 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e113 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e114 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e115 = peg$literalExpectation("\u201C", false); + var peg$e116 = peg$literalExpectation("\u201D", false); + var peg$e117 = peg$literalExpectation("\"", false); + var peg$e118 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e119 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e120 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e121 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e122 = peg$classExpectation([","], false, false); + var peg$e123 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + + var peg$f0 = function(filters) { return applyDefaults(filters); }; + var peg$f1 = function(head, tail) { + const allFilters = [head, ...tail.map(([_, filter]) => filter)] + .filter(Boolean) + .filter((filter) => filter.right); + rawFilterList = allFilters.map(createRawFilter).filter(Boolean); + + const filtersWithoutDefaults = allFilters.filter((filter) => !filter.isDefault); + + if (!filtersWithoutDefaults.length) { + return null; + } + + const keywords = filtersWithoutDefaults.filter( + (filter) => + filter.left === "keyword" || filter.right?.left === "keyword" + ); + const nonKeywords = filtersWithoutDefaults.filter( + (filter) => + filter.left !== "keyword" && filter.right?.left !== "keyword" + ); + + const keywordFilter = buildFilter( + "eq", + "keyword", + keywords + .map((filter) => filter.right.replaceAll(/^(['"])(.*)\1$/g, "$2")) + .flat() + ); + if (keywordFilter.right.length > 0) { + nonKeywords.push(keywordFilter); + } + return nonKeywords.reduce((result, filter) => + buildFilter("and", result, filter) + ); + }; + var peg$f2 = function(key, op, value) { + updateDefaultValues(key, value); + return {...buildFilter(op, key, value), isDefault: true}; + }; + var peg$f3 = function(value) { + //handle no-breaking space + let word; + if (Array.isArray(value)) { + word = value.join(""); + } else { + word = value; + } + return buildFilter("eq", "keyword", word); + }; + var peg$f4 = function(neg, field, op, values) { + expectingNestedQuote = false; nameOperator = false; - const key = field; - let operator = op; + const key = field; + let operator = op; - // Apply negation only when "-" is present and the key can be negated - if (neg && !nonNegatableKeys.has(key)) { - if (operator === "eq") { - operator = "neq"; + // Apply negation only when "-" is present and the key can be negated + if (neg && !nonNegatableKeys.has(key)) { + if (operator === "eq") { + operator = "neq"; + } } - } - return buildFilter(operator, key, values); - } - function peg$f5(rest) { - const suffix = rest.replaceAll(/^-+/g, ""); - return "reportField-" + (suffix ? suffix : ""); - } - function peg$f6(k) { - nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); - return k; - } - function peg$f7(parts, empty) { - const value = parts.flat().filter(Boolean).map((word) => { - if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { - return word.slice(1, -1); + return buildFilter(operator, key, values); + }; + var peg$f5 = function(rest) { + const suffix = rest.replaceAll(/^-+/g, ""); + return "reportField-" + (suffix ? suffix : ""); + }; + var peg$f6 = function(k) { + nameOperator = (k === "from" || k === "to" || k === "payer" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); + return k; + }; + var peg$f7 = function(parts, empty) { + const value = parts.flat().filter(Boolean).map((word) => { + if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { + return word.slice(1, -1); + } + return word; + }); + if (value.length > 1) { + return value.filter((word) => word.length > 0); } - return word; - }); - if (value.length > 1) { - return value.filter((word) => word.length > 0); - } - return value[0]; - } - function peg$f8() { return "date"; } - function peg$f9() { return "amount"; } - function peg$f10() { return "merchant"; } - function peg$f11() { return "description"; } - function peg$f12() { return "reportID"; } - function peg$f13() { return "keyword"; } - function peg$f14() { return "in"; } - function peg$f15() { return "currency"; } - function peg$f16() { return "groupCurrency"; } - function peg$f17() { return "tag"; } - function peg$f18() { return "category"; } - function peg$f19() { return "to"; } - function peg$f20() { return "exporter"; } - function peg$f21() { return "payer"; } - function peg$f22() { return "taxRate"; } - function peg$f23() { return "cardID"; } - function peg$f24() { return "from"; } - function peg$f25() {return "attendee" } - function peg$f26() { return "expenseType"; } - function peg$f27() { return "withdrawalType"; } - function peg$f28() { return "withdrawalID"; } - function peg$f29() { return "billable"; } - function peg$f30() { return "reimbursable"; } - function peg$f31() { return "type"; } - function peg$f32() { return "status"; } - function peg$f33() { return "sortBy"; } - function peg$f34() { return "sortOrder"; } - function peg$f35() { return "policyID"; } - function peg$f36() { return "submitted"; } - function peg$f37() { return "approved"; } - function peg$f38() { return "paid"; } - function peg$f39() { return "exported"; } - function peg$f40() { return "posted"; } - function peg$f41() { return "withdrawn"; } - function peg$f42() { return "groupBy"; } - function peg$f43() { return "limit"; } - function peg$f44() { return "feed"; } - function peg$f45() { return "title"; } - function peg$f46() { return "assignee"; } - function peg$f47() { return "createdBy"; } - function peg$f48() { return "action"; } - function peg$f49() {return "total"; } - function peg$f50() {return "has"; } - function peg$f51() {return "is"; } - function peg$f52() {return "purchaseAmount" } - function peg$f53() {return "purchaseCurrency" } - function peg$f54() { - isColumnsContext = true; - return "columns"; - } - function peg$f55() {return "view" } - function peg$f56() { return isColumnsContext; } - function peg$f57() { return "perDiem"; } - function peg$f58() { return "drafts"; } - function peg$f59() { return "originalamount"; } - function peg$f60() { return "taxAmount"; } - function peg$f61() { return "taxrate"; } - function peg$f62() { return "policyname"; } - function peg$f63() { return "withdrawalID"; } - function peg$f64() { return "bankAccount"; } - function peg$f65() { return "reportID"; } - function peg$f66() { return "base62ReportID"; } - function peg$f67() { return "exportedto"; } - function peg$f68() { return "exportedTo"; } - function peg$f69() { return "exportedTo"; } - function peg$f70() { return "exchangeRate"; } - function peg$f71() { return "reimbursableTotal"; } - function peg$f72() { return "nonReimbursableTotal"; } - function peg$f73() { return "groupFrom"; } - function peg$f74() { return "groupExpenses"; } - function peg$f75() { return "groupTotal"; } - function peg$f76() { return "groupCard"; } - function peg$f77() { return "groupFeed"; } - function peg$f78() { return "groupBankAccount"; } - function peg$f79() { return "groupWithdrawn"; } - function peg$f80() { return "groupWithdrawalID"; } - function peg$f81() { return "groupCategory"; } - function peg$f82() { return "groupTag"; } - function peg$f83() { return "groupMerchant"; } - function peg$f84() { return "groupmonth"; } - function peg$f85() { return "groupweek"; } - function peg$f86() { return "groupyear"; } - function peg$f87() { return "groupquarter"; } - function peg$f88() { return "contains"; } - function peg$f89() { return "eq"; } - function peg$f90() { return "neq"; } - function peg$f91() { return "gte"; } - function peg$f92() { return "gt"; } - function peg$f93() { return "lte"; } - function peg$f94() { return "lt"; } - function peg$f95(o) { - if (nameOperator) { - expectingNestedQuote = (o === "eq" || o === "contains"); - } - isColumnsContext = false; - return o; - } - function peg$f96(chars) { return chars.join("").trim(); } - function peg$f97() { - isColumnsContext = false; - return "and"; - } - function peg$f98() { return expectingNestedQuote; } - function peg$f99(start, inner, end) { //handle no-breaking space - return [...start, '"', ...inner, '"', ...end].join(""); - } - function peg$f100(start) {return "“" } - function peg$f101(start) {return "”" } - function peg$f102(start) {return "\"" } - function peg$f103(start, inner, end) { + return value[0]; + }; + var peg$f8 = function() { return "date"; }; + var peg$f9 = function() { return "amount"; }; + var peg$f10 = function() { return "merchant"; }; + var peg$f11 = function() { return "description"; }; + var peg$f12 = function() { return "reportID"; }; + var peg$f13 = function() { return "keyword"; }; + var peg$f14 = function() { return "in"; }; + var peg$f15 = function() { return "currency"; }; + var peg$f16 = function() { return "groupCurrency"; }; + var peg$f17 = function() { return "tag"; }; + var peg$f18 = function() { return "category"; }; + var peg$f19 = function() { return "to"; }; + var peg$f20 = function() { return "exporter"; }; + var peg$f21 = function() { return "payer"; }; + var peg$f22 = function() { return "taxRate"; }; + var peg$f23 = function() { return "cardID"; }; + var peg$f24 = function() { return "from"; }; + var peg$f25 = function() {return "attendee"}; + var peg$f26 = function() { return "expenseType"; }; + var peg$f27 = function() { return "withdrawalType"; }; + var peg$f28 = function() { return "withdrawalID"; }; + var peg$f29 = function() { return "billable"; }; + var peg$f30 = function() { return "reimbursable"; }; + var peg$f31 = function() { return "type"; }; + var peg$f32 = function() { return "status"; }; + var peg$f33 = function() { return "sortBy"; }; + var peg$f34 = function() { return "sortOrder"; }; + var peg$f35 = function() { return "policyID"; }; + var peg$f36 = function() { return "submitted"; }; + var peg$f37 = function() { return "approved"; }; + var peg$f38 = function() { return "paid"; }; + var peg$f39 = function() { return "exported"; }; + var peg$f40 = function() { return "posted"; }; + var peg$f41 = function() { return "withdrawn"; }; + var peg$f42 = function() { return "groupBy"; }; + var peg$f43 = function() { return "limit"; }; + var peg$f44 = function() { return "feed"; }; + var peg$f45 = function() { return "title"; }; + var peg$f46 = function() { return "assignee"; }; + var peg$f47 = function() { return "createdBy"; }; + var peg$f48 = function() { return "action"; }; + var peg$f49 = function() {return "total"; }; + var peg$f50 = function() {return "has"; }; + var peg$f51 = function() {return "is"; }; + var peg$f52 = function() {return "purchaseAmount"}; + var peg$f53 = function() {return "purchaseCurrency"}; + var peg$f54 = function() { + isColumnsContext = true; + return "columns"; +}; + var peg$f55 = function() {return "view"}; + var peg$f56 = function() { return isColumnsContext; }; + var peg$f57 = function() { return "perDiem"; }; + var peg$f58 = function() { return "drafts"; }; + var peg$f59 = function() { return "originalamount"; }; + var peg$f60 = function() { return "taxAmount"; }; + var peg$f61 = function() { return "taxrate"; }; + var peg$f62 = function() { return "policyname"; }; + var peg$f63 = function() { return "withdrawalID"; }; + var peg$f64 = function() { return "bankAccount"; }; + var peg$f65 = function() { return "reportID"; }; + var peg$f66 = function() { return "base62ReportID"; }; + var peg$f67 = function() { return "exportedto"; }; + var peg$f68 = function() { return "exportedTo"; }; + var peg$f69 = function() { return "exportedTo"; }; + var peg$f70 = function() { return "exchangeRate"; }; + var peg$f71 = function() { return "reimbursableTotal"; }; + var peg$f72 = function() { return "nonReimbursableTotal"; }; + var peg$f73 = function() { return "groupFrom"; }; + var peg$f74 = function() { return "groupExpenses"; }; + var peg$f75 = function() { return "groupTotal"; }; + var peg$f76 = function() { return "groupCard"; }; + var peg$f77 = function() { return "groupFeed"; }; + var peg$f78 = function() { return "groupBankAccount"; }; + var peg$f79 = function() { return "groupWithdrawn"; }; + var peg$f80 = function() { return "groupWithdrawalID"; }; + var peg$f81 = function() { return "groupCategory"; }; + var peg$f82 = function() { return "groupTag"; }; + var peg$f83 = function() { return "groupMerchant"; }; + var peg$f84 = function() { return "groupmonth"; }; + var peg$f85 = function() { return "groupweek"; }; + var peg$f86 = function() { return "groupyear"; }; + var peg$f87 = function() { return "groupquarter"; }; + var peg$f88 = function() { return "contains"; }; + var peg$f89 = function() { return "eq"; }; + var peg$f90 = function() { return "neq"; }; + var peg$f91 = function() { return "gte"; }; + var peg$f92 = function() { return "gt"; }; + var peg$f93 = function() { return "lte"; }; + var peg$f94 = function() { return "lt"; }; + var peg$f95 = function(o) { + if (nameOperator) { + expectingNestedQuote = (o === "eq" || o === "contains"); + } + isColumnsContext = false; + return o; + }; + var peg$f96 = function(chars) { return chars.join("").trim(); }; + var peg$f97 = function() { + isColumnsContext = false; + return "and"; +}; + var peg$f98 = function() { return expectingNestedQuote; }; + var peg$f99 = function(start, inner, end) { //handle no-breaking space + return [...start, '"', ...inner, '"', ...end].join(""); + }; + var peg$f100 = function(start) {return "“"}; + var peg$f101 = function(start) {return "”"}; + var peg$f102 = function(start) {return "\""}; + var peg$f103 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); - } - let peg$currPos = options.peg$currPos | 0; - let peg$savedPos = peg$currPos; - const peg$posDetailsCache = [{ line: 1, column: 1 }]; - let peg$maxFailPos = peg$currPos; - let peg$maxFailExpected = options.peg$maxFailExpected || []; - let peg$silentFails = options.peg$silentFails | 0; +}; + var peg$currPos = options.peg$currPos | 0; + var peg$savedPos = peg$currPos; + var peg$posDetailsCache = [{ line: 1, column: 1 }]; + var peg$maxFailPos = peg$currPos; + var peg$maxFailExpected = options.peg$maxFailExpected || []; + var peg$silentFails = options.peg$silentFails | 0; - let peg$result; + var peg$result; if (options.startRule) { if (!(options.startRule in peg$startRuleFunctions)) { @@ -642,7 +654,7 @@ function peg$parse(input, options) { return { source: peg$source, start: peg$savedPos, - end: peg$currPos, + end: peg$currPos }; } @@ -670,20 +682,12 @@ function peg$parse(input, options) { throw peg$buildSimpleError(message, location); } - function peg$getUnicode(pos = peg$currPos) { - const cp = input.codePointAt(pos); - if (cp === undefined) { - return ""; - } - return String.fromCodePoint(cp); - } - function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text, ignoreCase }; + return { type: "literal", text: text, ignoreCase: ignoreCase }; } - function peg$classExpectation(parts, inverted, ignoreCase, unicode) { - return { type: "class", parts, inverted, ignoreCase, unicode }; + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; } function peg$anyExpectation() { @@ -695,12 +699,12 @@ function peg$parse(input, options) { } function peg$otherExpectation(description) { - return { type: "other", description }; + return { type: "other", description: description }; } function peg$computePosDetails(pos) { - let details = peg$posDetailsCache[pos]; - let p; + var details = peg$posDetailsCache[pos]; + var p; if (details) { return details; @@ -715,7 +719,7 @@ function peg$parse(input, options) { details = peg$posDetailsCache[p]; details = { line: details.line, - column: details.column, + column: details.column }; while (p < pos) { @@ -736,21 +740,21 @@ function peg$parse(input, options) { } function peg$computeLocation(startPos, endPos, offset) { - const startPosDetails = peg$computePosDetails(startPos); - const endPosDetails = peg$computePosDetails(endPos); + var startPosDetails = peg$computePosDetails(startPos); + var endPosDetails = peg$computePosDetails(endPos); - const res = { + var res = { source: peg$source, start: { offset: startPos, line: startPosDetails.line, - column: startPosDetails.column, + column: startPosDetails.column }, end: { offset: endPos, line: endPosDetails.line, - column: endPosDetails.column, - }, + column: endPosDetails.column + } }; if (offset && peg$source && (typeof peg$source.offset === "function")) { res.start = peg$source.offset(res.start); @@ -784,7 +788,7 @@ function peg$parse(input, options) { } function peg$parsequery() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -800,7 +804,7 @@ function peg$parse(input, options) { } function peg$parsefilterList() { - let s0, s1, s2, s3, s4, s5; + var s0, s1, s2, s3, s4, s5; s0 = peg$currPos; s1 = peg$parsefilter(); @@ -840,7 +844,7 @@ function peg$parse(input, options) { } function peg$parsefilter() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = peg$parsestandardFilter(); @@ -861,7 +865,7 @@ function peg$parse(input, options) { } function peg$parsedefaultFilter() { - let s0, s1, s2, s3, s4, s5, s6; + var s0, s1, s2, s3, s4, s5, s6; s0 = peg$currPos; s1 = peg$parse_(); @@ -892,7 +896,7 @@ function peg$parse(input, options) { } function peg$parsefreeTextFilter() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = peg$parse_(); @@ -934,7 +938,7 @@ function peg$parse(input, options) { } function peg$parsestandardFilter() { - let s0, s1, s2, s3, s4, s5, s6, s7; + var s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = peg$parse_(); @@ -975,12 +979,12 @@ function peg$parse(input, options) { } function peg$parsereportFieldDynamic() { - let s0, s1, s2, s3, s4, s5, s6, s7; + var s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c1) { - peg$currPos += (12); + peg$currPos += 12; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e2); } @@ -988,7 +992,7 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c2) { - peg$currPos += (11); + peg$currPos += 11; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e3); } @@ -1096,7 +1100,7 @@ function peg$parse(input, options) { } function peg$parsekey() { - let s0, s1; + var s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -1237,7 +1241,7 @@ function peg$parse(input, options) { } function peg$parsefilterKey() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = peg$parsekey(); @@ -1251,7 +1255,7 @@ function peg$parse(input, options) { } function peg$parsedefaultKey() { - let s0, s1; + var s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -1296,7 +1300,7 @@ function peg$parse(input, options) { } function peg$parseidentifier() { - let s0, s1, s2, s3, s4, s5, s6; + var s0, s1, s2, s3, s4, s5, s6; s0 = peg$currPos; s1 = []; @@ -1419,12 +1423,12 @@ function peg$parse(input, options) { } function peg$parsedate() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c4) { - peg$currPos += (4); + peg$currPos += 4; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e9); } @@ -1439,12 +1443,12 @@ function peg$parse(input, options) { } function peg$parseamount() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c5) { - peg$currPos += (6); + peg$currPos += 6; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e10); } @@ -1459,12 +1463,12 @@ function peg$parse(input, options) { } function peg$parsemerchant() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c6) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e11); } @@ -1479,12 +1483,12 @@ function peg$parse(input, options) { } function peg$parsedescription() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c7) { - peg$currPos += (11); + peg$currPos += 11; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e12); } @@ -1499,11 +1503,11 @@ function peg$parse(input, options) { } function peg$parsereportID() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 8); if (s0.toLowerCase() === peg$c8) { - peg$currPos += (8); + peg$currPos += 8; } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e13); } @@ -1512,7 +1516,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c9) { - peg$currPos += (9); + peg$currPos += 9; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e14); } @@ -1528,12 +1532,12 @@ function peg$parse(input, options) { } function peg$parsekeyword() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c10) { - peg$currPos += (7); + peg$currPos += 7; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e15); } @@ -1548,12 +1552,12 @@ function peg$parse(input, options) { } function peg$parsein() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c11) { - peg$currPos += (2); + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e16); } @@ -1568,12 +1572,12 @@ function peg$parse(input, options) { } function peg$parsecurrency() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c12) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e17); } @@ -1588,7 +1592,7 @@ function peg$parse(input, options) { } function peg$parsegroupCurrency() { - let s0, s1; + var s0, s1; if (input.substr(peg$currPos, 13) === peg$c13) { s0 = peg$c13; @@ -1601,7 +1605,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c14) { - peg$currPos += (14); + peg$currPos += 14; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e19); } @@ -1617,12 +1621,12 @@ function peg$parse(input, options) { } function peg$parsetag() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); if (s1.toLowerCase() === peg$c15) { - peg$currPos += (3); + peg$currPos += 3; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e20); } @@ -1637,12 +1641,12 @@ function peg$parse(input, options) { } function peg$parsecategory() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c16) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e21); } @@ -1657,12 +1661,12 @@ function peg$parse(input, options) { } function peg$parseto() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c17) { - peg$currPos += (2); + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e22); } @@ -1677,12 +1681,12 @@ function peg$parse(input, options) { } function peg$parseexporter() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c18) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e23); } @@ -1697,12 +1701,12 @@ function peg$parse(input, options) { } function peg$parsepayer() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c19) { - peg$currPos += (5); + peg$currPos += 5; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e24); } @@ -1717,11 +1721,11 @@ function peg$parse(input, options) { } function peg$parsetaxRate() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 7); if (s0.toLowerCase() === peg$c20) { - peg$currPos += (7); + peg$currPos += 7; } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e25); } @@ -1730,7 +1734,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c21) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e26); } @@ -1746,7 +1750,7 @@ function peg$parse(input, options) { } function peg$parsecardID() { - let s0, s1; + var s0, s1; if (input.substr(peg$currPos, 6) === peg$c22) { s0 = peg$c22; @@ -1759,7 +1763,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c23) { - peg$currPos += (4); + peg$currPos += 4; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e28); } @@ -1775,12 +1779,12 @@ function peg$parse(input, options) { } function peg$parsefrom() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c24) { - peg$currPos += (4); + peg$currPos += 4; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e29); } @@ -1795,12 +1799,12 @@ function peg$parse(input, options) { } function peg$parseattendee() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c25) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e30); } @@ -1815,7 +1819,7 @@ function peg$parse(input, options) { } function peg$parseexpenseType() { - let s0, s1; + var s0, s1; if (input.substr(peg$currPos, 11) === peg$c26) { s0 = peg$c26; @@ -1828,7 +1832,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c27) { - peg$currPos += (12); + peg$currPos += 12; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e32); } @@ -1844,7 +1848,7 @@ function peg$parse(input, options) { } function peg$parsewithdrawalType() { - let s0, s1; + var s0, s1; if (input.substr(peg$currPos, 14) === peg$c28) { s0 = peg$c28; @@ -1857,7 +1861,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c29) { - peg$currPos += (15); + peg$currPos += 15; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e34); } @@ -1873,11 +1877,11 @@ function peg$parse(input, options) { } function peg$parsewithdrawalID() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 12); if (s0.toLowerCase() === peg$c30) { - peg$currPos += (12); + peg$currPos += 12; } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e35); } @@ -1886,7 +1890,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c31) { - peg$currPos += (13); + peg$currPos += 13; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e36); } @@ -1902,12 +1906,12 @@ function peg$parse(input, options) { } function peg$parsebillable() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c32) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e37); } @@ -1922,12 +1926,12 @@ function peg$parse(input, options) { } function peg$parsereimbursable() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c33) { - peg$currPos += (12); + peg$currPos += 12; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e38); } @@ -1942,12 +1946,12 @@ function peg$parse(input, options) { } function peg$parsetype() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c34) { - peg$currPos += (4); + peg$currPos += 4; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e39); } @@ -1962,12 +1966,12 @@ function peg$parse(input, options) { } function peg$parsestatus() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c35) { - peg$currPos += (6); + peg$currPos += 6; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e40); } @@ -1982,7 +1986,7 @@ function peg$parse(input, options) { } function peg$parsesortBy() { - let s0, s1; + var s0, s1; if (input.substr(peg$currPos, 6) === peg$c36) { s0 = peg$c36; @@ -1995,7 +1999,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c37) { - peg$currPos += (7); + peg$currPos += 7; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e42); } @@ -2011,7 +2015,7 @@ function peg$parse(input, options) { } function peg$parsesortOrder() { - let s0, s1; + var s0, s1; if (input.substr(peg$currPos, 9) === peg$c38) { s0 = peg$c38; @@ -2024,7 +2028,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c39) { - peg$currPos += (10); + peg$currPos += 10; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e44); } @@ -2040,7 +2044,7 @@ function peg$parse(input, options) { } function peg$parsepolicyID() { - let s0, s1; + var s0, s1; if (input.substr(peg$currPos, 8) === peg$c40) { s0 = peg$c40; @@ -2053,7 +2057,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c41) { - peg$currPos += (9); + peg$currPos += 9; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e46); } @@ -2069,12 +2073,12 @@ function peg$parse(input, options) { } function peg$parsesubmitted() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c42) { - peg$currPos += (9); + peg$currPos += 9; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e47); } @@ -2089,12 +2093,12 @@ function peg$parse(input, options) { } function peg$parseapproved() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c43) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e48); } @@ -2109,12 +2113,12 @@ function peg$parse(input, options) { } function peg$parsepaid() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c44) { - peg$currPos += (4); + peg$currPos += 4; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e49); } @@ -2129,12 +2133,12 @@ function peg$parse(input, options) { } function peg$parseexported() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c45) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e50); } @@ -2149,12 +2153,12 @@ function peg$parse(input, options) { } function peg$parseposted() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c46) { - peg$currPos += (6); + peg$currPos += 6; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e51); } @@ -2169,12 +2173,12 @@ function peg$parse(input, options) { } function peg$parsewithdrawn() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c47) { - peg$currPos += (9); + peg$currPos += 9; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e52); } @@ -2189,11 +2193,11 @@ function peg$parse(input, options) { } function peg$parsegroupBy() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 7); if (s0.toLowerCase() === peg$c48) { - peg$currPos += (7); + peg$currPos += 7; } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e53); } @@ -2202,7 +2206,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c49) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e54); } @@ -2218,12 +2222,12 @@ function peg$parse(input, options) { } function peg$parselimit() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c50) { - peg$currPos += (5); + peg$currPos += 5; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e55); } @@ -2238,12 +2242,12 @@ function peg$parse(input, options) { } function peg$parsefeed() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c51) { - peg$currPos += (4); + peg$currPos += 4; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e56); } @@ -2258,12 +2262,12 @@ function peg$parse(input, options) { } function peg$parsetitle() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c52) { - peg$currPos += (5); + peg$currPos += 5; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e57); } @@ -2278,12 +2282,12 @@ function peg$parse(input, options) { } function peg$parseassignee() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c53) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e58); } @@ -2298,11 +2302,11 @@ function peg$parse(input, options) { } function peg$parsecreatedBy() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 9); if (s0.toLowerCase() === peg$c54) { - peg$currPos += (9); + peg$currPos += 9; } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e59); } @@ -2311,7 +2315,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c55) { - peg$currPos += (10); + peg$currPos += 10; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e60); } @@ -2327,12 +2331,12 @@ function peg$parse(input, options) { } function peg$parseaction() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c56) { - peg$currPos += (6); + peg$currPos += 6; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e61); } @@ -2347,12 +2351,12 @@ function peg$parse(input, options) { } function peg$parsetotal() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c57) { - peg$currPos += (5); + peg$currPos += 5; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e62); } @@ -2367,12 +2371,12 @@ function peg$parse(input, options) { } function peg$parsehas() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); if (s1.toLowerCase() === peg$c58) { - peg$currPos += (3); + peg$currPos += 3; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e63); } @@ -2387,12 +2391,12 @@ function peg$parse(input, options) { } function peg$parseis() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 2); if (s1.toLowerCase() === peg$c59) { - peg$currPos += (2); + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e64); } @@ -2407,11 +2411,11 @@ function peg$parse(input, options) { } function peg$parsepurchaseAmount() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 14); if (s0.toLowerCase() === peg$c60) { - peg$currPos += (14); + peg$currPos += 14; } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e65); } @@ -2420,7 +2424,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c61) { - peg$currPos += (15); + peg$currPos += 15; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e66); } @@ -2436,11 +2440,11 @@ function peg$parse(input, options) { } function peg$parsepurchaseCurrency() { - let s0, s1; + var s0, s1; s0 = input.substr(peg$currPos, 16); if (s0.toLowerCase() === peg$c62) { - peg$currPos += (16); + peg$currPos += 16; } else { s0 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e67); } @@ -2449,7 +2453,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 17); if (s1.toLowerCase() === peg$c63) { - peg$currPos += (17); + peg$currPos += 17; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e68); } @@ -2465,12 +2469,12 @@ function peg$parse(input, options) { } function peg$parsecolumns() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 7); if (s1.toLowerCase() === peg$c64) { - peg$currPos += (7); + peg$currPos += 7; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e69); } @@ -2485,12 +2489,12 @@ function peg$parse(input, options) { } function peg$parseview() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = input.substr(peg$currPos, 4); if (s1.toLowerCase() === peg$c65) { - peg$currPos += (4); + peg$currPos += 4; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e70); } @@ -2505,7 +2509,7 @@ function peg$parse(input, options) { } function peg$parsevalues() { - let s0, s1, s2; + var s0, s1, s2; s0 = peg$currPos; peg$savedPos = peg$currPos; @@ -2539,7 +2543,7 @@ function peg$parse(input, options) { } function peg$parsecolumnsValues() { - let s0; + var s0; s0 = peg$parseoriginalAmount(); if (s0 === peg$FAILED) { @@ -2625,12 +2629,12 @@ function peg$parse(input, options) { } function peg$parseperDiem() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c66) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e71); } @@ -2662,12 +2666,12 @@ function peg$parse(input, options) { } function peg$parsedraft() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 6); if (s1.toLowerCase() === peg$c67) { - peg$currPos += (6); + peg$currPos += 6; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e72); } @@ -2675,7 +2679,7 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 5); if (s1.toLowerCase() === peg$c68) { - peg$currPos += (5); + peg$currPos += 5; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e73); } @@ -2708,12 +2712,12 @@ function peg$parse(input, options) { } function peg$parseoriginalAmount() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c69) { - peg$currPos += (15); + peg$currPos += 15; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e74); } @@ -2745,12 +2749,12 @@ function peg$parse(input, options) { } function peg$parsetaxAmount() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 3); if (s1.toLowerCase() === peg$c70) { - peg$currPos += (3); + peg$currPos += 3; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e75); } @@ -2782,12 +2786,12 @@ function peg$parse(input, options) { } function peg$parsetaxRateValue() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 8); if (s1.toLowerCase() === peg$c21) { - peg$currPos += (8); + peg$currPos += 8; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e26); } @@ -2819,12 +2823,12 @@ function peg$parse(input, options) { } function peg$parsepolicyName() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c71) { - peg$currPos += (11); + peg$currPos += 11; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e76); } @@ -2856,12 +2860,12 @@ function peg$parse(input, options) { } function peg$parsewithdrawalId() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c31) { - peg$currPos += (13); + peg$currPos += 13; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e36); } @@ -2893,12 +2897,12 @@ function peg$parse(input, options) { } function peg$parsebankAccount() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 12); if (s1.toLowerCase() === peg$c72) { - peg$currPos += (12); + peg$currPos += 12; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e77); } @@ -2930,12 +2934,12 @@ function peg$parse(input, options) { } function peg$parsereportId() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c73) { - peg$currPos += (14); + peg$currPos += 14; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e78); } @@ -2967,12 +2971,12 @@ function peg$parse(input, options) { } function peg$parsebase62ReportId() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c9) { - peg$currPos += (9); + peg$currPos += 9; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e14); } @@ -3004,12 +3008,12 @@ function peg$parse(input, options) { } function peg$parseexportedToColumnValue() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c74) { - peg$currPos += (11); + peg$currPos += 11; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e79); } @@ -3041,7 +3045,7 @@ function peg$parse(input, options) { } function peg$parseexportedToKeyEnd() { - let s0, s1, s2; + var s0, s1, s2; s0 = peg$parsewordBoundary(); if (s0 === peg$FAILED) { @@ -3067,12 +3071,12 @@ function peg$parse(input, options) { } function peg$parseexportedTo() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c75) { - peg$currPos += (10); + peg$currPos += 10; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e81); } @@ -3103,7 +3107,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c74) { - peg$currPos += (11); + peg$currPos += 11; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e79); } @@ -3136,12 +3140,12 @@ function peg$parse(input, options) { } function peg$parseexchangeRate() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c76) { - peg$currPos += (13); + peg$currPos += 13; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e82); } @@ -3173,12 +3177,12 @@ function peg$parse(input, options) { } function peg$parsereimbursableTotal() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); if (s1.toLowerCase() === peg$c77) { - peg$currPos += (18); + peg$currPos += 18; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e83); } @@ -3210,12 +3214,12 @@ function peg$parse(input, options) { } function peg$parsenonReimbursableTotal() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 22); if (s1.toLowerCase() === peg$c78) { - peg$currPos += (22); + peg$currPos += 22; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e84); } @@ -3247,12 +3251,12 @@ function peg$parse(input, options) { } function peg$parsegroupFrom() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c79) { - peg$currPos += (10); + peg$currPos += 10; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e85); } @@ -3284,12 +3288,12 @@ function peg$parse(input, options) { } function peg$parsegroupExpenses() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c80) { - peg$currPos += (14); + peg$currPos += 14; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e86); } @@ -3321,12 +3325,12 @@ function peg$parse(input, options) { } function peg$parsegroupTotal() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c81) { - peg$currPos += (11); + peg$currPos += 11; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e87); } @@ -3358,12 +3362,12 @@ function peg$parse(input, options) { } function peg$parsegroupCard() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c82) { - peg$currPos += (10); + peg$currPos += 10; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e88); } @@ -3395,12 +3399,12 @@ function peg$parse(input, options) { } function peg$parsegroupFeed() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c83) { - peg$currPos += (10); + peg$currPos += 10; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e89); } @@ -3432,12 +3436,12 @@ function peg$parse(input, options) { } function peg$parsegroupBankAccount() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 18); if (s1.toLowerCase() === peg$c84) { - peg$currPos += (18); + peg$currPos += 18; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e90); } @@ -3469,12 +3473,12 @@ function peg$parse(input, options) { } function peg$parsegroupWithdrawn() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 15); if (s1.toLowerCase() === peg$c85) { - peg$currPos += (15); + peg$currPos += 15; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e91); } @@ -3506,12 +3510,12 @@ function peg$parse(input, options) { } function peg$parsegroupWithdrawalId() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 19); if (s1.toLowerCase() === peg$c86) { - peg$currPos += (19); + peg$currPos += 19; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e92); } @@ -3543,12 +3547,12 @@ function peg$parse(input, options) { } function peg$parsegroupCategory() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c87) { - peg$currPos += (14); + peg$currPos += 14; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e93); } @@ -3580,12 +3584,12 @@ function peg$parse(input, options) { } function peg$parsegroupTag() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 9); if (s1.toLowerCase() === peg$c88) { - peg$currPos += (9); + peg$currPos += 9; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e94); } @@ -3617,12 +3621,12 @@ function peg$parse(input, options) { } function peg$parsegroupMerchant() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); if (s1.toLowerCase() === peg$c89) { - peg$currPos += (14); + peg$currPos += 14; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e95); } @@ -3654,12 +3658,12 @@ function peg$parse(input, options) { } function peg$parsegroupMonth() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); if (s1.toLowerCase() === peg$c90) { - peg$currPos += (11); + peg$currPos += 11; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e96); } @@ -3691,12 +3695,12 @@ function peg$parse(input, options) { } function peg$parsegroupWeek() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c91) { - peg$currPos += (10); + peg$currPos += 10; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e97); } @@ -3728,12 +3732,12 @@ function peg$parse(input, options) { } function peg$parsegroupYear() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 10); if (s1.toLowerCase() === peg$c92) { - peg$currPos += (10); + peg$currPos += 10; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e98); } @@ -3765,12 +3769,12 @@ function peg$parse(input, options) { } function peg$parsegroupQuarter() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.substr(peg$currPos, 13); if (s1.toLowerCase() === peg$c93) { - peg$currPos += (13); + peg$currPos += 13; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e99); } @@ -3802,7 +3806,7 @@ function peg$parse(input, options) { } function peg$parseoperator() { - let s0, s1; + var s0, s1; peg$silentFails++; s0 = peg$currPos; @@ -3918,7 +3922,7 @@ function peg$parse(input, options) { } function peg$parsefilterOperator() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = peg$parseoperator(); @@ -3932,7 +3936,7 @@ function peg$parse(input, options) { } function peg$parsealphanumeric() { - let s0, s1, s2; + var s0, s1, s2; peg$silentFails++; s0 = peg$currPos; @@ -3973,7 +3977,7 @@ function peg$parse(input, options) { } function peg$parselogicalAnd() { - let s0, s1; + var s0, s1; s0 = peg$currPos; s1 = peg$parse_(); @@ -3985,7 +3989,7 @@ function peg$parse(input, options) { } function peg$parse_() { - let s0, s1; + var s0, s1; peg$silentFails++; s0 = []; @@ -3994,7 +3998,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -4003,16 +4007,18 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e109); } + if (peg$silentFails === 0) { peg$fail(peg$e110); } } } peg$silentFails--; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e109); } return s0; } function peg$parsequotedString() { - let s0, s1, s2; + var s0, s1, s2; s0 = peg$currPos; peg$savedPos = peg$currPos; @@ -4043,7 +4049,7 @@ function peg$parse(input, options) { } function peg$parsesimpleQuotedString() { - let s0, s1, s2, s3, s4, s5, s6; + var s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -4053,7 +4059,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4062,7 +4068,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } } s2 = input.charAt(peg$currPos); @@ -4070,7 +4076,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4079,7 +4085,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -4088,7 +4094,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } } s4 = input.charAt(peg$currPos); @@ -4096,7 +4102,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } if (s4 !== peg$FAILED) { s5 = []; @@ -4130,14 +4136,14 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } return s0; } function peg$parsenestedQuotedString() { - let s0, s1, s2, s3, s4, s5, s6; + var s0, s1, s2, s3, s4, s5, s6; peg$silentFails++; s0 = peg$currPos; @@ -4147,7 +4153,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4156,7 +4162,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e111); } + if (peg$silentFails === 0) { peg$fail(peg$e112); } } } s2 = input.charAt(peg$currPos); @@ -4164,7 +4170,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4173,7 +4179,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4193,7 +4199,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; @@ -4224,7 +4230,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e116); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; @@ -4255,7 +4261,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e116); } + if (peg$silentFails === 0) { peg$fail(peg$e117); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; @@ -4278,7 +4284,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e113); } + if (peg$silentFails === 0) { peg$fail(peg$e114); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4298,7 +4304,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e114); } + if (peg$silentFails === 0) { peg$fail(peg$e115); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; @@ -4329,7 +4335,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e115); } + if (peg$silentFails === 0) { peg$fail(peg$e116); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; @@ -4360,7 +4366,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e116); } + if (peg$silentFails === 0) { peg$fail(peg$e117); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; @@ -4392,14 +4398,14 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e110); } + if (peg$silentFails === 0) { peg$fail(peg$e111); } } return s0; } function peg$parseclosingQuote() { - let s0, s1, s2, s3; + var s0, s1, s2, s3; s0 = peg$currPos; s1 = input.charAt(peg$currPos); @@ -4407,7 +4413,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e112); } + if (peg$silentFails === 0) { peg$fail(peg$e113); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4436,7 +4442,7 @@ function peg$parse(input, options) { } function peg$parsevalidClosingCondition() { - let s0, s1, s2, s3, s4; + var s0, s1, s2, s3, s4; s0 = peg$currPos; s1 = []; @@ -4445,7 +4451,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e117); } + if (peg$silentFails === 0) { peg$fail(peg$e118); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4454,7 +4460,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e117); } + if (peg$silentFails === 0) { peg$fail(peg$e118); } } } s2 = []; @@ -4463,7 +4469,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e118); } + if (peg$silentFails === 0) { peg$fail(peg$e119); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -4472,7 +4478,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e118); } + if (peg$silentFails === 0) { peg$fail(peg$e119); } } } s3 = []; @@ -4481,7 +4487,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e119); } + if (peg$silentFails === 0) { peg$fail(peg$e120); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -4490,7 +4496,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e119); } + if (peg$silentFails === 0) { peg$fail(peg$e120); } } } s4 = peg$parseoperator(); @@ -4509,7 +4515,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e120); } + if (peg$silentFails === 0) { peg$fail(peg$e121); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4518,7 +4524,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e120); } + if (peg$silentFails === 0) { peg$fail(peg$e121); } } } s2 = peg$currPos; @@ -4565,7 +4571,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e121); } + if (peg$silentFails === 0) { peg$fail(peg$e122); } } } } @@ -4574,14 +4580,14 @@ function peg$parse(input, options) { } function peg$parsewordBoundary() { - let s0, s1; + var s0, s1; s0 = input.charAt(peg$currPos); if (peg$r13.test(s0)) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e122); } + if (peg$silentFails === 0) { peg$fail(peg$e123); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -4761,35 +4767,29 @@ function peg$parse(input, options) { peg$result = peg$startRuleFunction(); - const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length); - function peg$throw() { - if (peg$result !== peg$FAILED && peg$currPos < input.length) { - peg$fail(peg$endExpectation()); - } - - throw peg$buildStructuredError( - peg$maxFailExpected, - peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null, - peg$maxFailPos < input.length - ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) - : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) - ); - } if (options.peg$library) { return /** @type {any} */ ({ peg$result, peg$currPos, peg$FAILED, peg$maxFailExpected, - peg$maxFailPos, - peg$success, - peg$throw: peg$success ? undefined : peg$throw, + peg$maxFailPos }); } - if (peg$success) { + if (peg$result !== peg$FAILED && peg$currPos === input.length) { return peg$result; } else { - peg$throw(); + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); } } From c033992b00661b7c3b78f10f4b44ec59ea11f118 Mon Sep 17 00:00:00 2001 From: Phuoc Hiep Date: Thu, 21 May 2026 13:47:33 +0700 Subject: [PATCH 004/781] chore(search): fix parser CI checks --- src/libs/SearchParser/baseRules.peggy | 2 +- tests/unit/Search/SearchQueryUtilsTest.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index 9b7c2dbf5136..27534cf1337c 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -179,7 +179,7 @@ filterOperator alphanumeric "word" = chars:[^ ,\t\n\r\xA0]+ { return chars.join("").trim(); } //handle no-breaking space -logicalAnd = _ { +logicalAnd = _ { isColumnsContext = false; return "and"; } diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index d867b2fae82f..a2b2973af823 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -2920,7 +2920,7 @@ describe('SearchQueryUtils', () => { expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); }); - it('should preserve group drilldown merchant exact match', () => { + it('should preserve group drill-down merchant exact match', () => { const queryJSON = buildSearchQueryJSON('type:expense merchant:I'); if (!queryJSON) { throw new Error('Expected queryJSON to be defined'); From 234ff702f184688ad91c126abd43fcdfbebae5bb Mon Sep 17 00:00:00 2001 From: Phuoc Hiep Date: Sat, 27 Jun 2026 05:48:16 +0700 Subject: [PATCH 005/781] feat(search): add merchant match type selector Add an Equal to / Contains match-type selector under the Merchant advanced filter (RHP page and popover). - Default Equal to -> serializes as merchant:value; Contains -> merchant*:value - Restore the operator from the query on reopen so saved searches round-trip - Preserve negated merchant as eq (-merchant:value -> neq) - Add dedicated search.filters.merchant translation keys (en source of truth) - Unit tests for build and round-trip behavior --- .../SearchAdvancedFiltersContent.tsx | 17 ++++-- .../TextInputFilterContent.tsx | 56 +++++++++++++++---- .../TextInputFilterContentPopupWrapper.tsx | 3 +- src/languages/en.ts | 5 ++ src/libs/SearchQueryUtils.ts | 29 ++++++++-- .../TextInputFilterContentPageWrapper.tsx | 3 +- src/types/form/SearchAdvancedFiltersForm.ts | 8 ++- tests/unit/Search/SearchQueryUtilsTest.ts | 51 ++++++++++++++++- 8 files changed, 145 insertions(+), 27 deletions(-) diff --git a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx index d4365dbb5518..37792e1245cb 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx @@ -3,6 +3,7 @@ import {isAmountFilterKey, isDateFilterKey} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; import type {FilterComponentsProps} from '..'; import type {AmountFilterContentProps} from './AmountFilterContent'; import type {CommonFilterContentProps} from './CommonFilterContent'; @@ -10,7 +11,7 @@ import type {DateFilterContentProps} from './DateFilterContent'; import type {ReportFieldFilterContentProps} from './ReportFieldFilterContent'; import type {TextInputFilterContentProps} from './TextInputFilterContent'; -type TextInputFilterContentWrapperProps = Pick; +type TextInputFilterContentWrapperProps = Pick; type AmountFilterContentWrapperProps = Pick; type DateFilterContentWrapperProps = Pick; type ReportFieldFilterContentWrapperProps = Pick; @@ -38,9 +39,11 @@ function getFilterFormValue(filter function SearchAdvancedFiltersContent({filterKey, values, policyIDQuery, ready, components, onChange}: SearchAdvancedFiltersContentProps) { const {Text: TextFilter, Amount: AmountFilter, Date: DateFilter, ReportField: ReportFieldFilter, Common: CommonFilter} = components; + const isMerchantFilter = filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT; + const merchantOperator = values?.[FILTER_KEYS.MERCHANT_OPERATOR] ?? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO; if ( - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || + isMerchantFilter || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD || @@ -49,10 +52,16 @@ function SearchAdvancedFiltersContent({filterKey, values, policyIDQuery, ready, ) { return ( onChange({[filterKey]: newValue})} + merchantOperator={isMerchantFilter ? merchantOperator : undefined} + onChange={(newValue, newMerchantOperator) => + onChange({ + [filterKey]: newValue, + ...(isMerchantFilter ? {[FILTER_KEYS.MERCHANT_OPERATOR]: newMerchantOperator ?? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO} : {}), + }) + } /> ); } diff --git a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx index 71082b1d8ad6..a95b43b49c6b 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx @@ -2,9 +2,12 @@ import React, {useState} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import Button from '@components/Button'; +import RadioButtonWithLabel from '@components/RadioButtonWithLabel'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import type CONST from '@src/CONST'; +import CONST from '@src/CONST'; +import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; import FilterComponents from '..'; type TextInputFilterContentProps = { @@ -19,31 +22,60 @@ type TextInputFilterContentProps = { largeButton?: boolean; autoFocus?: boolean; style?: StyleProp; - onChange: (value: string | undefined) => void; + merchantOperator?: MerchantMatchType; + onChange: (value: string | undefined, merchantOperator?: MerchantMatchType) => void; }; -function TextInputFilterContent({filterKey, value: initialValue, autoFocus, largeButton, style, onChange}: TextInputFilterContentProps) { +function TextInputFilterContent({filterKey, value: initialValue, autoFocus, largeButton, style, merchantOperator: initialMerchantOperator, onChange}: TextInputFilterContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [value, setValue] = useState(initialValue); + const shouldShowMerchantMatchType = filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT; + const [merchantOperator, setMerchantOperator] = useState(initialMerchantOperator ?? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + const merchantMatchTypeItems: Array<{value: MerchantMatchType; label: string}> = [ + { + value: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + label: translate('search.filters.merchant.equalTo'), + }, + { + value: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + label: translate('search.filters.merchant.contains'), + }, + ]; return ( - setValue(typeof v === 'string' ? v : undefined)} - /> + + setValue(typeof v === 'string' ? v : undefined)} + /> + {shouldShowMerchantMatchType && ( + + {translate('search.filters.merchant.matchType')} + {merchantMatchTypeItems.map((item) => ( + setMerchantOperator(item.value)} + label={item.label} + /> + ))} + + )} + + + ); +} + +export default MerchantFilterContent; +export type {MerchantFilterContentProps}; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx index fc62ace7f3de..f826386eccf4 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx @@ -12,10 +12,12 @@ import React from 'react'; import type {AmountFilterContentProps} from './AmountFilterContent'; import type {DateFilterContentProps} from './DateFilterContent'; +import type {MerchantFilterContentProps} from './MerchantFilterContent'; import type {ReportFieldFilterContentProps} from './ReportFieldFilterContent'; import type {TextInputFilterContentProps} from './TextInputFilterContent'; -type TextInputFilterContentWrapperProps = Pick; +type TextInputFilterContentWrapperProps = Pick; +type MerchantFilterContentWrapperProps = Pick; type AmountFilterContentWrapperProps = Pick; type DateFilterContentWrapperProps = Pick; type ReportFieldFilterContentWrapperProps = Pick; @@ -28,6 +30,7 @@ type SearchAdvancedFiltersContentProps = { values: Partial | undefined; ready?: boolean; components: { + Merchant: React.ComponentType; Text: React.ComponentType; Amount: React.ComponentType; Date: React.ComponentType; @@ -94,24 +97,33 @@ function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, ); } + if (baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + const MerchantFilter = components.Merchant; + const isNegated = !!values?.[FILTER_KEYS.MERCHANT_NOT]; + const value = values?.[isNegated ? FILTER_KEYS.MERCHANT_NOT : FILTER_KEYS.MERCHANT]; + const merchantOperator = values?.[FILTER_KEYS.MERCHANT_OPERATOR] ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; + return ( + + ); + } + if (isTextFilterKey(baseFilterKey)) { const {isNegated, value} = getFilterNegatableValue(baseFilterKey, values); const TextFilter = components.Text; - const isMerchantFilter = baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT; - const merchantOperator = values?.[FILTER_KEYS.MERCHANT_OPERATOR] ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; return ( - onChange({ - ...getFilterFormValues(baseFilterKey, newValue, negated), - ...(isMerchantFilter ? {[FILTER_KEYS.MERCHANT_OPERATOR]: newMerchantOperator ?? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO} : {}), - }) - } + onChange={(newValue, negated) => onChange(getFilterFormValues(baseFilterKey, newValue, negated))} /> ); } @@ -133,4 +145,11 @@ function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, } export default SearchAdvancedFiltersContent; -export type {TextInputFilterContentWrapperProps, AmountFilterContentWrapperProps, DateFilterContentWrapperProps, ReportFieldFilterContentWrapperProps, ListFilterContentWrapperProps}; +export type { + TextInputFilterContentWrapperProps, + MerchantFilterContentWrapperProps, + AmountFilterContentWrapperProps, + DateFilterContentWrapperProps, + ReportFieldFilterContentWrapperProps, + ListFilterContentWrapperProps, +}; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx index 40d2fc36922a..1cf9f64a4c7e 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx @@ -1,5 +1,4 @@ import Button from '@components/ButtonComposed'; -import MerchantMatchTypeSelector from '@components/Search/FilterComponents/MerchantMatchTypeSelector'; import NegatableFilter from '@components/Search/FilterComponents/NegatableFilter'; import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; import type {ReportFieldTextKey, SearchTextFilterKeys} from '@components/Search/types'; @@ -13,7 +12,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {FILTER_VIEW_MAP} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; import type {TextInput as RNTextInput, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -28,30 +26,18 @@ type TextInputFilterContentProps = { size?: Exclude, typeof CONST.BUTTON_SIZE.SMALL>; autoFocus?: boolean; style?: StyleProp; - merchantOperator?: MerchantMatchType; - onChange: (value: string | undefined, isNegated: boolean, merchantOperator?: MerchantMatchType) => void; + onChange: (value: string | undefined, isNegated: boolean) => void; }; function isTextInput(element: BaseTextInputRef | RNTextInput | null): element is RNTextInput { return !!element && 'isFocused' in element; } -function TextInputFilterContent({ - baseFilterKey, - value: initialValue, - isNegated: initialIsNegated, - autoFocus, - size, - style, - merchantOperator: initialMerchantOperator, - onChange, -}: TextInputFilterContentProps) { +function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: initialIsNegated, autoFocus, size, style, onChange}: TextInputFilterContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [value, setValue] = useState(initialValue); const [isNegated, setIsNegated] = useState(initialIsNegated); - const shouldShowMerchantMatchType = baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT && !isNegated; - const [merchantOperator, setMerchantOperator] = useState(initialMerchantOperator ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); const {inputCallbackRef} = useAutoFocusInput(); const error = useTextFilterValidation(baseFilterKey, value); @@ -71,7 +57,7 @@ function TextInputFilterContent({ onChangeText={setValue} accessibilityLabel={label} role={CONST.ROLE.PRESENTATION} - containerStyles={shouldShowMerchantMatchType ? [styles.ph5, styles.mb5] : [styles.ph5]} + containerStyles={[styles.ph5]} /> ); @@ -81,17 +67,8 @@ function TextInputFilterContent({ baseFilterKey={baseFilterKey} isNegated={isNegated} onNegationChange={setIsNegated} - style={shouldShowMerchantMatchType ? styles.flex1 : undefined} > - - {filterInput} - {shouldShowMerchantMatchType && ( - - )} - + {filterInput} - + ); } diff --git a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx index 6242f00b45ae..077606301b62 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx @@ -15,7 +15,7 @@ import {FILTER_VIEW_MAP} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import type {ComponentRef} from 'react'; +import type {ComponentRef, ReactNode} from 'react'; import type {TextInput as RNTextInput, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -30,6 +30,12 @@ type TextInputFilterContentProps = { autoFocus?: boolean; style?: StyleProp; buttonText?: string; + buttonStyles?: StyleProp; + sentryLabel?: string; + + /** Renders extra controls under the input, inside the scrollable area. Receives the current negation state, so the content can change when the user negates the filter. */ + renderBelowInput?: (isNegated: boolean) => ReactNode; + onChange: (value: string | undefined, isNegated: boolean) => void; }; @@ -37,7 +43,19 @@ function isTextInput(element: BaseTextInputRef | ComponentRef { @@ -59,6 +77,7 @@ function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: } onChange(value, isNegated); }} + sentryLabel={sentryLabel} > {buttonText ?? translate('common.confirm')} @@ -93,6 +112,7 @@ function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: containerStyles={[styles.ph5]} /> + {renderBelowInput?.(isNegated)} {shouldButtonBeInScrollView && button} {!shouldButtonBeInScrollView && button} diff --git a/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx b/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx index 4cf7cf356cb3..cbcf743be352 100644 --- a/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx +++ b/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx @@ -9,6 +9,8 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; @@ -44,12 +46,15 @@ function MerchantMatchTypeSelector({value, onChange}: MerchantMatchTypeSelectorP {translate('search.filters.merchant.matchType')} - onChange(item.keyForList)} - shouldSingleExecuteRowSelect - /> + {/* Inside a ScrollView a flex list can shrink to zero height when the keyboard is open, so reserve room for every row */} + + onChange(item.keyForList)} + shouldSingleExecuteRowSelect + /> + ); } From 93596d722a8995f995559910839368f74cd95d00 Mon Sep 17 00:00:00 2001 From: Phuoc Hiep Date: Wed, 7 Oct 2026 17:18:08 +0700 Subject: [PATCH 313/781] Load the English locale in SearchSavePageTest The Merchant filter value is now shown through translate as Contains "Coffee Shop". The test loaded no locale, so translate returned key paths and the Merchant text never rendered. Load English before the tests and assert the localized label users see. --- tests/unit/pages/Search/SearchSavePageTest.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unit/pages/Search/SearchSavePageTest.tsx b/tests/unit/pages/Search/SearchSavePageTest.tsx index 9ca581247845..258c06a1d59e 100644 --- a/tests/unit/pages/Search/SearchSavePageTest.tsx +++ b/tests/unit/pages/Search/SearchSavePageTest.tsx @@ -11,6 +11,7 @@ import * as SearchUIUtils from '@libs/SearchUIUtils'; import SearchSavePage from '@pages/Search/SearchSavePage'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {Card, CardList} from '@src/types/onyx'; @@ -53,16 +54,24 @@ jest.mocked(useOnyx).mockImplementation((key) => { return [undefined, {status: 'loaded'}]; } }); +// Without a loaded locale, translate returns key paths and translated filter values never show the user's text. +beforeAll(() => IntlStore.load(CONST.LOCALES.EN)); beforeEach(() => jest.clearAllMocks()); it.each([[['12']], [['12', '23']], [['123']]])('renders canonical card selection %j exactly', (cardID) => { + // Given a saved search form with cards, feeds, tax rates and a Merchant filter that uses the default "contains" match form = {cardID, feed: ['feed-a', 'feed-b'], taxRate: ['tax-a', 'tax-b'], merchant: 'Coffee Shop'}; + + // When the save page renders the filter summary const output = JSON.stringify(render().toJSON()); + + // Then only the selected card titles, the feed and tax display values, and the localized Merchant label show const expectedDescriptions = cardID.at(0) === '123' ? [false, false, true] : [true, cardID.length === 2, false]; expect(['Selected Alpha', 'Selected Beta', 'Unselected Overlap'].map((text) => output.includes(text))).toEqual(expectedDescriptions); expect(['12', '23', '123'].map((rawID) => output.includes(rawID))).toEqual([false, false, false]); expect(jest.mocked(useFilterFeedValue)).toHaveBeenCalledWith(['feed-a', 'feed-b']); expect(jest.mocked(useFilterTaxRateValue)).toHaveBeenCalledWith(['tax-a', 'tax-b']); - expect(['feed:feed-a|feed-b', 'tax:tax-a|tax-b', 'Coffee Shop'].every((text) => output.includes(text))).toBe(true); + // The output is a JSON string, so the quotes in the Merchant label are escaped. + expect(['feed:feed-a|feed-b', 'tax:tax-a|tax-b', 'Contains \\"Coffee Shop\\"'].every((text) => output.includes(text))).toBe(true); }); it('renders scalar feed and tax values without calling array display hooks', () => { jest.spyOn(SearchUIUtils, 'mapFiltersFormToLabelValueList').mockReturnValueOnce([ From 6a5d4bfc5b884f01ff3436501dd003791cfa3678 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 7 Oct 2026 17:27:54 +0700 Subject: [PATCH 314/781] add create button on category dropdown --- src/CONST/index.ts | 1 + src/ROUTES.ts | 2 +- .../CategoryPicker/CategoryPickerModal.tsx | 39 ++++++++++- .../sections/CategoryFieldDropdown.tsx | 16 +++++ src/libs/PolicyUtils/index.ts | 6 ++ .../step/DynamicIOURequestStepCategory.tsx | 6 +- tests/unit/PolicyUtilsTest.ts | 31 +++++++++ .../components/CategoryPickerModalTest.tsx | 64 +++++++++++++++++++ 8 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 tests/unit/components/CategoryPickerModalTest.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index bde832c72f9f..7549ab973a4b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9618,6 +9618,7 @@ const CONST = { HOURS_FIELD: 'RequestConfirmationList-HoursField', TIME_RATE_FIELD: 'RequestConfirmationList-TimeRateField', CATEGORY_FIELD: 'RequestConfirmationList-CategoryField', + ADD_CATEGORY_BUTTON: 'RequestConfirmationList-AddCategoryButton', DATE_FIELD: 'RequestConfirmationList-DateField', TAG_FIELD: 'RequestConfirmationList-TagField', TAX_RATE_FIELD: 'RequestConfirmationList-TaxRateField', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5f66dbb0ad7d..53c0db212256 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2045,7 +2045,7 @@ const DYNAMIC_ROUTES = { }, MONEY_REQUEST_STEP_CATEGORY_CREATE: { path: 'add-category', - entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY], + entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY, SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, SCREENS.MONEY_REQUEST.CREATE], }, MONEY_REQUEST_ATTENDEE: { path: 'attendees', diff --git a/src/components/CategoryPicker/CategoryPickerModal.tsx b/src/components/CategoryPicker/CategoryPickerModal.tsx index 3fee5ad3886e..e55d429e2959 100644 --- a/src/components/CategoryPicker/CategoryPickerModal.tsx +++ b/src/components/CategoryPicker/CategoryPickerModal.tsx @@ -1,17 +1,26 @@ +import Icon from '@components/Icon'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasuredContent/types'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; +import Tooltip from '@components/Tooltip'; import useKeyboardState from '@hooks/useKeyboardState'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getEnabledCategoriesCount} from '@libs/CategoryUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getSelectionListPopoverContentHeight from '@libs/getSelectionListPopoverContentHeight'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -47,6 +56,9 @@ type CategoryPickerModalProps = { /** Whether the pop-over shrinks to the height its list needs, treating `popoverHeight` as a ceiling. */ shouldFitContentHeight?: boolean; + /** Shows a header with an add button, for users who can add a category to the workspace */ + onAddCategory?: () => void; + /** Whether the pop-over may flip to the other side of the anchor when it overflows. It shifts by a whole pop-over height, so a caller that already picked the side turns this off. */ shouldSwitchPositionIfOverflow?: boolean; } & Omit; @@ -64,8 +76,12 @@ function CategoryPickerModal({ popoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT, shouldFitContentHeight = false, shouldSwitchPositionIfOverflow = true, + onAddCategory, }: CategoryPickerModalProps) { const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Plus']); const StyleUtils = useStyleUtils(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- must match PopoverWithMeasuredContent's dock decision (bottom-docked only when isSmallScreenWidth) const {isSmallScreenWidth} = useResponsiveLayout(); @@ -77,7 +93,8 @@ function CategoryPickerModal({ const categoriesCount = policyCategories ?? 0; const isSearchable = categoriesCount >= CONST.STANDARD_LIST_ITEM_LIMIT; - const estimatedContentHeight = getSelectionListPopoverContentHeight({optionCount: Math.max(renderedRowCount ?? categoriesCount, 1), isSearchable}); + const headerHeight = onAddCategory ? variables.componentSizeNormal : 0; + const estimatedContentHeight = getSelectionListPopoverContentHeight({optionCount: Math.max(renderedRowCount ?? categoriesCount, 1), isSearchable}) + headerHeight; // A bottom sheet is sized by the screen, so the content estimate only applies to the pop-over. const resolvedHeight = shouldFitContentHeight && !isSmallScreenWidth ? Math.min(popoverHeight, estimatedContentHeight) : popoverHeight; @@ -111,6 +128,26 @@ function CategoryPickerModal({ enableEdgeToEdgeBottomSafeAreaPadding > + {!!onAddCategory && ( + + {translate('common.category')} + + + + + + + )} { + onClose(); + TransitionTracker.runAfterTransitions({ + callback: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.path)), + waitForUpcomingTransition: true, + }); + }; + const handleSelected = (item: ListItem) => { // `CategoryPickerModal` hands back an empty item when the selected category is tapped again, clearing it. updateCategory(item.searchText ?? ''); @@ -64,6 +79,7 @@ function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose policyID={policy?.id} selectedCategory={selectedCategory} onSelected={handleSelected} + onAddCategory={canCreateCategoryInSitu(policy, currentUserLogin) ? openAddCategory : undefined} /> ); } diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index 124d59d9e476..a2d896a2404b 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -1551,6 +1551,11 @@ function hasAccountingConnections(policy: OnyxEntry) { return !!getCurrentConnectionName(policy) || hasSupportedOnlyOnOldDotIntegration(policy); } +/** Whether the user can add a category to the workspace straight from an expense's category picker */ +function canCreateCategoryInSitu(policy: OnyxEntry, login?: string) { + return canEditWorkspaceSettings(policy, login) && !hasAccountingConnections(policy) && !!policy?.areCategoriesEnabled; +} + function hasAccountingFeatureConnection(policy: OnyxEntry) { return hasAccountingConnections(policy) || hasUnsupportedIntegration(policy); } @@ -2792,6 +2797,7 @@ export { goBackFromInvalidPolicy, hasAccountingFeatureConnection, hasAccountingConnections, + canCreateCategoryInSitu, shouldShowSyncError, shouldShowCustomUnitsError, shouldShowEmployeeListError, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx index d5b747707863..e6b935ca4414 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx @@ -29,7 +29,7 @@ import {getSelectedWorkspacePolicyID, pickReportForPolicy} from '@libs/IOUUtils' import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import {canEditWorkspaceSettings, hasAccountingConnections, isGroupPolicy} from '@libs/PolicyUtils'; +import {canCreateCategoryInSitu, canEditWorkspaceSettings, isGroupPolicy} from '@libs/PolicyUtils'; import {getTransactionDetails, isSelfDM} from '@libs/ReportUtils'; import {getRequestType} from '@libs/TransactionUtils'; @@ -100,9 +100,7 @@ function DynamicIOURequestStepCategory({ const categoryForDisplay = isCategoryMissing(transactionCategory) ? '' : transactionCategory; - const canCreateCategoryInSitu = canEditWorkspaceSettings(policy, currentUserEmailParam) && !hasAccountingConnections(policy) && !!policy?.areCategoriesEnabled; - - const createCategoryMenuItems = canCreateCategoryInSitu + const createCategoryMenuItems = canCreateCategoryInSitu(policy, currentUserEmailParam) ? [ { icon: expensifyIcons.Plus, diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 2fe5cc9d895d..64f68a6a689f 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -7,6 +7,7 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import { arePolicyRulesEnabled, + canCreateCategoryInSitu, canEditWorkspaceSettings, canMemberAssignRole, canMemberManageMemberWithRole, @@ -430,6 +431,36 @@ describe('PolicyUtils', () => { }); }); + describe('canCreateCategoryInSitu', () => { + const categoriesPolicy = (role: ValueOf, areCategoriesEnabled = true): Policy => ({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role, + areCategoriesEnabled, + connections: undefined, + }); + + it('lets an admin add a category from the picker when the workspace uses categories', () => { + // Given an admin of a workspace with categories on and no accounting integration + // When we check whether the category picker can offer to add one + // Then it can, as the full-page selector's "+" does + expect(canCreateCategoryInSitu(categoriesPolicy(CONST.POLICY.ROLE.ADMIN))).toBe(true); + }); + + it('does not let a member add a category', () => { + // Given a member, who cannot change workspace settings + // When we check whether the category picker can offer to add one + // Then it cannot, so the pop-over hides its add button + expect(canCreateCategoryInSitu(categoriesPolicy(CONST.POLICY.ROLE.USER))).toBe(false); + }); + + it('does not offer to add a category when the workspace has categories turned off', () => { + // Given an admin of a workspace that has turned categories off + // When we check whether the category picker can offer to add one + // Then it cannot, because a new category would have nowhere to show + expect(canCreateCategoryInSitu(categoriesPolicy(CONST.POLICY.ROLE.ADMIN, false))).toBe(false); + }); + }); + describe('isArchivedPolicy', () => { it('returns true when the policy has an archivedDate', () => { const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), archivedDate: '2024-01-01'}; diff --git a/tests/unit/components/CategoryPickerModalTest.tsx b/tests/unit/components/CategoryPickerModalTest.tsx new file mode 100644 index 000000000000..8e3a034c7598 --- /dev/null +++ b/tests/unit/components/CategoryPickerModalTest.tsx @@ -0,0 +1,64 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import CategoryPickerModal from '@components/CategoryPicker/CategoryPickerModal'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@components/CategoryPicker', () => () => null); +jest.mock( + '@components/PopoverWithMeasuredContent', + () => + ({children}: {children: React.ReactNode}) => + children, +); + +const renderModal = (onAddCategory?: () => void) => + render( + {}} + anchorPosition={{horizontal: 0, vertical: 0}} + policyID="policyID" + onAddCategory={onAddCategory} + />, + ); + +describe('CategoryPickerModal', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('shows an add button that adds a category for users who can create one', () => { + // Given a user who can add categories to the workspace, as the full-page selector's "+" allows + const onAddCategory = jest.fn(); + renderModal(onAddCategory); + + // When they press the add button in the pop-over's header + fireEvent.press(screen.getByLabelText('workspace.categories.addCategory')); + + // Then the pop-over hands over to the add-category flow, so the pop-over offers the same action the full page does + expect(screen.getByText('common.category')).toBeOnTheScreen(); + expect(onAddCategory).toHaveBeenCalledTimes(1); + }); + + it('shows no header for users who cannot create a category', () => { + // Given a user who cannot add categories, e.g. a member or a workspace synced with an accounting integration + // When the pop-over opens + renderModal(); + + // Then there is no add button or header, matching the full page that hides its "+" for them + expect(screen.queryByLabelText('workspace.categories.addCategory')).toBeNull(); + expect(screen.queryByText('common.category')).toBeNull(); + }); +}); From 434ec5aa526100365083438b85679048c30d108d Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 7 Oct 2026 14:28:52 +0200 Subject: [PATCH 315/781] fix: iOS tab labels fall back for non-Latin scripts and truncate to the tab width, tabs keep their state across the breakpoint, wide Spend opens last search --- .../NativeTabNavigator/NativeTabLayout.tsx | 15 ++--- .../NativeTabNavigator/TabPressListeners.tsx | 4 +- .../NativeTabNavigator/tabIconRasterizer.ts | 63 +++++++++++-------- .../useNativeTabBarOptions/index.ios.ts | 7 ++- src/styles/variables.ts | 2 + 5 files changed, 51 insertions(+), 40 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx index 8dc42d5dfe64..11884432fdb6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -39,18 +39,11 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { // The buttons follow whatever the navigator decided for the bar itself. const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; - if (!shouldUseNarrowLayout) { - return ( - - - {children} - - ); - } - + // The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them. return ( - - {children} + + {!shouldUseNarrowLayout && } + {children} ) { const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index fa55746b019c..4c2efd1d9bd5 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -1,9 +1,9 @@ import FontUtils from '@styles/utils/FontUtils'; import type {NativeBottomTabIcon} from '@react-navigation/bottom-tabs/unstable'; -import type {SkCanvas, SkFont, SkImage, SkPath} from '@shopify/react-native-skia'; +import type {SkCanvas, SkImage, SkParagraph, SkPath} from '@shopify/react-native-skia'; -import {BlendMode, ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia} from '@shopify/react-native-skia'; +import {BlendMode, ClipOp, FillType, FilterMode, FontWeight, ImageFormat, MipmapMode, Skia, TextAlign} from '@shopify/react-native-skia'; import {PixelRatio} from 'react-native'; import type {NativeTabGlyph, NativeTabName} from './NATIVE_TAB_GLYPHS'; @@ -29,6 +29,8 @@ type TabIconLabel = { text: string; color: string; isBold: boolean; + /** Width of the tab's slot in the bar, past which the label is truncated with an ellipsis. */ + maxWidth: number; }; type TabIconParams = { @@ -45,7 +47,6 @@ const MAX_CACHED_ICONS = 64; const iconCache = new Map(); const glyphPathCache = new Map(); -const labelFontCache = new Map(); function getGlyphPaths(name: NativeTabName): SkPath[] { const cachedPaths = glyphPathCache.get(name); @@ -68,18 +69,19 @@ function getGlyphPaths(name: NativeTabName): SkPath[] { return paths; } -function getLabelFont(isBold: boolean, size: number): SkFont { - const key = `${isBold}|${size}`; - const cachedFont = labelFontCache.get(key); - if (cachedFont) { - return cachedFont; - } - const typeface = Skia.FontMgr.System().matchFamilyStyle(FontUtils.fontFamily.single.EXP_NEUE.fontFamily, { - weight: isBold ? FontWeight.Bold : FontWeight.Normal, - }); - const font = Skia.Font(typeface, size); - labelFontCache.set(key, font); - return font; +/** A paragraph rather than a single font, because only a paragraph falls back to a system font for scripts Expensify Neue lacks. */ +function makeLabelParagraph({text, color, isBold, maxWidth}: TabIconLabel, fontSize: number, scale: number): SkParagraph { + const paragraph = Skia.ParagraphBuilder.Make({maxLines: 1, ellipsis: '…', textAlign: TextAlign.Left}) + .pushStyle({ + color: Skia.Color(color), + fontFamilies: [FontUtils.fontFamily.single.EXP_NEUE.fontFamily], + fontSize: fontSize * scale, + fontStyle: {weight: isBold ? FontWeight.Bold : FontWeight.Normal}, + }) + .addText(text) + .build(); + paragraph.layout(maxWidth * scale); + return paragraph; } function drawGlyph(canvas: SkCanvas, name: NativeTabName, left: number, top: number, size: number, color: string) { @@ -141,15 +143,15 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe // every label lands at the same height. const rowSize = label ? Math.max(layout.glyphSize, layout.avatarSize) * scale : contentSize; const labelTop = (rowSize + layout.glyphSize * scale) / 2 + layout.labelGap * scale; - const font = label ? getLabelFont(label.isBold, layout.labelFontSize * scale) : undefined; - const labelWidth = label && font ? font.measureText(label.text).width : 0; - const labelMetrics = font?.getMetrics(); - const labelHeight = labelMetrics ? labelMetrics.descent - labelMetrics.ascent : 0; + const paragraph = label ? makeLabelParagraph(label, layout.labelFontSize, scale) : undefined; + const labelWidth = paragraph?.getLongestLine() ?? 0; + const labelHeight = paragraph?.getHeight() ?? 0; const canvasWidth = Math.ceil(Math.max(contentSize, labelWidth)); const canvasHeight = Math.ceil(label ? labelTop + labelHeight : rowSize); const surface = Skia.Surface.Make(canvasWidth, canvasHeight); if (!surface) { + paragraph?.dispose(); return undefined; } @@ -164,12 +166,9 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe if (dotColor) { drawStatusDot(canvas, contentLeft + contentSize, contentTop, layout.dotRadius * scale, layout.dotCutout * scale, dotColor); } - if (label && font && labelMetrics) { - const paint = Skia.Paint(); - paint.setColor(Skia.Color(label.color)); - paint.setAntiAlias(true); - canvas.drawText(label.text, (canvasWidth - labelWidth) / 2, labelTop - labelMetrics.ascent, paint, font); - paint.dispose(); + if (paragraph) { + paragraph.paint(canvas, (canvasWidth - labelWidth) / 2, labelTop); + paragraph.dispose(); } surface.flush(); @@ -191,7 +190,19 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe */ function getTabIcon(layout: TabIconLayout, params: TabIconParams): NativeBottomTabIcon | undefined { const {name, color, avatar, dotColor, label} = params; - const key = [name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, layout.glyphSize, layout.avatarSize, layout.dotRadius, layout.dotCutout].join('|'); + const key = [ + name, + avatar?.uri ?? color, + dotColor, + label?.text, + label?.color, + label?.isBold, + label?.maxWidth, + layout.glyphSize, + layout.avatarSize, + layout.dotRadius, + layout.dotCutout, + ].join('|'); if (iconCache.has(key)) { return iconCache.get(key); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts index 686535daa74e..e6f46ed27404 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -1,4 +1,5 @@ import useTheme from '@hooks/useTheme'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; import getTabIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; @@ -35,6 +36,10 @@ const TAB_ICON_LAYOUT: TabIconLayout = { function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { const theme = useTheme(); const avatar = useTabAvatarImage(isAccountAvatarShown); + const {windowWidth} = useWindowDimensions(); + // One of the tabs never has a bar item, so the bar splits its width between the others. + const barItemCount = Object.keys(tabLabels).length - 1; + const labelMaxWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount; const screenOptions: NativeBottomTabNavigationOptions = { headerShown: false, @@ -57,7 +62,7 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, d color: isSelected ? theme.iconMenu : theme.icon, avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, dotColor: dotColors[name], - label: {text: tabLabels[name], color: isSelected ? theme.text : theme.textSupporting, isBold: isSelected}, + label: {text: tabLabels[name], color: isSelected ? theme.text : theme.textSupporting, isBold: isSelected, maxWidth: labelMaxWidth}, }); const inactiveIcon = getIcon(false); const activeIcon = getIcon(true); diff --git a/src/styles/variables.ts b/src/styles/variables.ts index a86fce69cfcd..d5eb6116b771 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -121,6 +121,8 @@ export default { nativeTabIconDotCutout: 2, /** Gap between the glyph and the label drawn under it in an iOS native tab bar icon. */ nativeTabIconLabelGap: 6, + /** Space between the screen edge and the first tab of the iOS 26 floating tab bar. */ + iosNativeTabBarHorizontalInset: 25, /** Material's BottomNavigationView row, without the gesture inset under it. */ androidNativeTabBarHeight: 80, /** Material's bar row (androidNativeTabBarHeight) plus the 16 dp Material keeps between a FAB and the bar. */ From 1b99159ffb4e1736227fe529eb6e219455ab87b9 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 7 Oct 2026 14:35:47 +0200 Subject: [PATCH 316/781] refactor: review fixes, narrow overlay split out, tab options from screenOptions, Android options file, offline indicator above the mWeb bar --- .../useTabRootScreenWrapperProps/index.tsx | 3 +- .../useTabRootScreenWrapperProps/types.ts | 4 +- .../NativeTabBarOverlay.tsx | 65 ++++++++++++++++++ .../NativeTabNavigator/NativeTabLayout.tsx | 53 ++------------- .../useNativeTabBarOptions/index.android.ts | 67 +++++++++++++++++++ .../useNativeTabBarOptions/index.ts | 61 ++--------------- .../Navigators/TabNavigator.native.tsx | 20 +++--- src/pages/Search/SearchPageNarrow/index.tsx | 2 +- src/pages/settings/InitialSettingsPage.tsx | 7 ++ src/styles/index.ts | 5 ++ src/styles/variables.ts | 7 +- 11 files changed, 174 insertions(+), 120 deletions(-) create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx index 5621ca35e2fd..5bffa89b7a7c 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -11,7 +11,7 @@ import TabBarBottomContent from '..'; /** * On narrow layouts the floating tab bar is laid over the bottom of a tab root screen, so the content runs under it - * down to the bottom edge of the screen. Each tab root's list ends with room for the bar. + * down to the bottom edge of the screen. Each tab root's list and the offline indicator end with room for the bar. */ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -20,6 +20,7 @@ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['sel return { bottomContent: , bottomContentStyle: shouldUseNarrowLayout ? styles.floatingTabBarOverlay : styles.overflowVisible, + offlineIndicatorStyle: shouldUseNarrowLayout ? styles.floatingTabBarOfflineIndicator : undefined, }; } diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts index dab83f0477d5..7c3807848145 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/types.ts @@ -1,5 +1,7 @@ import type {ScreenWrapperContainerProps} from '@components/ScreenWrapper/ScreenWrapperContainer'; +import type {ScreenWrapperOfflineIndicatorsProps} from '@components/ScreenWrapper/ScreenWrapperOfflineIndicators'; -type TabRootScreenWrapperProps = Pick; +type TabRootScreenWrapperProps = Pick & + Pick; export default TabRootScreenWrapperProps; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx new file mode 100644 index 000000000000..827233ecc9b2 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabBarOverlay.tsx @@ -0,0 +1,65 @@ +import FloatingCameraButton from '@components/FloatingCameraButton'; +import FloatingGPSButton from '@components/FloatingGPSButton'; +import DebugTabView from '@components/Navigation/DebugTabView'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; + +import useOnyx from '@hooks/useOnyx'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; + +import type {NativeTabLayoutProps} from './NativeTabLayout'; + +import NativeTabBarShadow from './NativeTabBarShadow'; +import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; + +/** What a narrow layout draws over the tabs along with the native bar, kept apart so wide layouts skip its hooks. */ +function NativeTabBarOverlay({state, descriptors}: Pick) { + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + const activeRoute = state.routes[state.index]; + const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; + // The buttons follow whatever the navigator decided for the bar itself. + const shouldShowNativeTabBar = !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; + + return ( + <> + {!!isDebugModeEnabled && shouldShowNativeTabBar && } + {shouldShowNativeTabBar && ( + // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. + // They leave faster than they come in, so they stop covering a bar that is still sliding out. + + + + + + + + + + + )} + + ); +} + +export default NativeTabBarOverlay; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx index 11884432fdb6..777c74986afd 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NativeTabLayout.tsx @@ -1,43 +1,21 @@ -import FloatingCameraButton from '@components/FloatingCameraButton'; -import FloatingGPSButton from '@components/FloatingGPSButton'; -import DebugTabView from '@components/Navigation/DebugTabView'; -import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import ROUTE_TO_NAVIGATION_TAB from '@components/Navigation/NavigationTabBar/ROUTE_TO_NAVIGATION_TAB'; - -import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; import TabNavigatorBar from '@libs/Navigation/AppNavigator/Navigators/TabNavigatorBar'; -import NavigationTabBarFloatingActionButton from '@pages/inbox/sidebar/NavigationTabBarFloatingActionButton'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; - import type {NativeBottomTabNavigatorProps} from '@react-navigation/bottom-tabs/unstable'; import React from 'react'; -import {StyleSheet, View} from 'react-native'; -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import {View} from 'react-native'; -import NativeTabBarShadow from './NativeTabBarShadow'; +import NativeTabBarOverlay from './NativeTabBarOverlay'; import TabPressListeners from './TabPressListeners'; -import {getFloatingButtonsBottom} from './useNativeTabBarOptions'; type NativeTabLayoutProps = Parameters>[0]; function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); const styles = useThemeStyles(); - const {bottom: bottomInset} = useSafeAreaInsets(); - const activeRoute = state.routes[state.index]; - const selectedTab = ROUTE_TO_NAVIGATION_TAB[activeRoute?.name ?? SCREENS.HOME] ?? NAVIGATION_TABS.HOME; - // The buttons follow whatever the navigator decided for the bar itself. - const shouldShowNativeTabBar = shouldUseNarrowLayout && !!activeRoute && descriptors[activeRoute.key]?.options.tabBarStyle?.display !== 'none'; // The tabs stay at the same place in the tree in both layouts, so crossing the breakpoint does not remount them. return ( @@ -48,28 +26,11 @@ function NativeTabLayout({children, state, descriptors}: NativeTabLayoutProps) { state={state} descriptors={descriptors} /> - {!!isDebugModeEnabled && shouldShowNativeTabBar && } - {shouldShowNativeTabBar && ( - // The shadow and the buttons belong to the bar, so they fade with it rather than appearing in place. - // They leave faster than they come in, so they stop covering a bar that is still sliding out. - - - - - - - - - - + {shouldUseNarrowLayout && ( + )} ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts new file mode 100644 index 000000000000..41a9ac389e2e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts @@ -0,0 +1,67 @@ +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; +import getTabIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; +import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; + +import variables from '@styles/variables'; + +import NAVIGATORS from '@src/NAVIGATORS'; + +import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; + +import type NativeTabBarOptionsParams from './types'; + +/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ +const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; + +/** + * Material draws each icon in its fixed icon slot, with a label of its own. The status dot is drawn into the icon rather + * than as a Material badge, because RNScreens paints every badge in the badge color of the selected tab. + */ +const TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconBottomBar, + avatarSize: variables.iconBottomBar, + dotRadius: variables.nativeTabIconDotRadius, + dotCutout: variables.nativeTabIconDotCutout, + labelGap: 0, + labelFontSize: 0, +}; + +function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { + const theme = useTheme(); + const styles = useThemeStyles(); + const avatar = useTabAvatarImage(isAccountAvatarShown); + + // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). + const screenOptions: NativeBottomTabNavigationOptions = { + headerShown: false, + tabBarActiveTintColor: theme.text, + tabBarInactiveTintColor: theme.textSupporting, + tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, + tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, + tabBarLabelVisibilityMode: 'labeled', + // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. + tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, + }; + + const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => { + // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. + const tabAvatar = name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined; + const dotColor = dotColors[name]; + const inactiveIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.icon, avatar: tabAvatar, dotColor}); + const activeIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.iconMenu, avatar: tabAvatar, dotColor}); + return { + tabBarLabel: tabLabels[name], + // A function, because React Navigation derives the selected icon only from a function. + tabBarIcon: inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined, + }; + }; + + return {screenOptions, getTabOptions}; +} + +export default useNativeTabBarOptions; +export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts index 41a9ac389e2e..2401d88a0b31 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ts @@ -1,67 +1,18 @@ -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - import type {NativeTabName} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/NATIVE_TAB_GLYPHS'; -import getTabIcon from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; -import type {TabIconLayout} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer'; -import useTabAvatarImage from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage'; - -import variables from '@styles/variables'; - -import NAVIGATORS from '@src/NAVIGATORS'; import type {NativeBottomTabNavigationOptions} from '@react-navigation/bottom-tabs/unstable'; import type NativeTabBarOptionsParams from './types'; -/** Material's bar sits above the system gesture inset, so the floating buttons clear both. */ -const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativeTabBarFloatingButtonsBottom + bottomInset; - -/** - * Material draws each icon in its fixed icon slot, with a label of its own. The status dot is drawn into the icon rather - * than as a Material badge, because RNScreens paints every badge in the badge color of the selected tab. - */ -const TAB_ICON_LAYOUT: TabIconLayout = { - glyphSize: variables.iconBottomBar, - avatarSize: variables.iconBottomBar, - dotRadius: variables.nativeTabIconDotRadius, - dotCutout: variables.nativeTabIconDotCutout, - labelGap: 0, - labelFontSize: 0, +type UseNativeTabBarOptions = (params: NativeTabBarOptionsParams) => { + screenOptions: NativeBottomTabNavigationOptions; + getTabOptions: (name: NativeTabName) => NativeBottomTabNavigationOptions; }; -function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { - const theme = useTheme(); - const styles = useThemeStyles(); - const avatar = useTabAvatarImage(isAccountAvatarShown); - - // The tint only reaches the labels, since the icons arrive already recolored (react-native-screens patch 003). - const screenOptions: NativeBottomTabNavigationOptions = { - headerShown: false, - tabBarActiveTintColor: theme.text, - tabBarInactiveTintColor: theme.textSupporting, - tabBarLabelStyle: {fontFamily: styles.textSmall.fontFamily, fontSize: styles.textSmall.fontSize}, - tabBarActiveIndicatorColor: theme.androidTabBarActiveIndicatorBG, - tabBarLabelVisibilityMode: 'labeled', - // Every tab shares one style, so the bar reads the current visibility in the same render that changed it. - tabBarStyle: {display: shouldShowNativeTabBar ? 'flex' : 'none', backgroundColor: theme.appBG}, - }; - - const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => { - // The active indicator pill marks the selected tab, so the avatar is the same image in both selection states. - const tabAvatar = name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined; - const dotColor = dotColors[name]; - const inactiveIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.icon, avatar: tabAvatar, dotColor}); - const activeIcon = getTabIcon(TAB_ICON_LAYOUT, {name, color: theme.iconMenu, avatar: tabAvatar, dotColor}); - return { - tabBarLabel: tabLabels[name], - // A function, because React Navigation derives the selected icon only from a function. - tabBarIcon: inactiveIcon && activeIcon ? ({focused}) => (focused ? activeIcon : inactiveIcon) : undefined, - }; - }; +/** Only iOS and Android have a native tab bar, so other platforms get no options. */ +const useNativeTabBarOptions: UseNativeTabBarOptions = () => ({screenOptions: {}, getTabOptions: () => ({})}); - return {screenOptions, getTabOptions}; -} +const getFloatingButtonsBottom = (bottomInset: number) => bottomInset; export default useNativeTabBarOptions; export {getFloatingButtonsBottom}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 6191e9d9b678..6972f020693c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -39,51 +39,49 @@ function TabNavigator() { useNativeTabNavigator(); const {screenOptions, getTabOptions} = useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}); // A tab with no bar item draws no icon. - const getOptions = (name: NativeTabName) => - name === tabWithoutBarItem - ? HIDDEN_TAB_OPTIONS - : {...getTabOptions(name), tabBarSelectionEnabled: isNativeTabSelectionEnabled(name, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored})}; + const getScreenOptions = ({route}: {route: {name: NativeTabName}}) => + route.name === tabWithoutBarItem + ? {...screenOptions, ...HIDDEN_TAB_OPTIONS} + : { + ...screenOptions, + ...getTabOptions(route.name), + tabBarSelectionEnabled: isNativeTabSelectionEnabled(route.name, {isAnonymousUser, isInboxAtChatList, isWorkspacesTabRestored}), + }; return ( ); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index c565d5c75d6b..7a7be17a0238 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -265,9 +265,9 @@ function SearchPageNarrow({ {!isMobileSelectionModeEnabled ? ( diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index f4989443a0b0..310ed058aabd 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -61,6 +61,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); const navigation = useNavigation(); const isDrawnOverTabs = useIsSettingsDrawnOverTabs(); + // The tab navigator keeps the full tab history, so going back returns to the tab the user opened Account from. // With nothing behind it, as after a deep link, Home replaces Account so that going back does not reopen it. const goBackFromAccount = () => { @@ -68,15 +69,19 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr navigation.goBack(); return; } + Navigation.navigate(ROUTES.HOME, {forceReplace: true}); }; + useAndroidBackButtonHandler(() => { if (!isDrawnOverTabs) { return false; } + goBackFromAccount(); return true; }); + const isScreenFocused = useIsSidebarRouteActive(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, shouldUseNarrowLayout); const previousUserPersonalDetails = usePrevious(currentUserPersonalDetails); const {accountMenuItemsData, generalMenuItemsData} = useInitialSettingsPageMenuData(currentUserPersonalDetails); @@ -92,10 +97,12 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr }, [hasAccountBeenSwitched]); const hasAccountBeenShown = useHasTabBeenShown(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR); + useEffect(() => { if (!hasAccountBeenShown) { return; } + openInitialSettingsPage(); }, [hasAccountBeenShown]); diff --git a/src/styles/index.ts b/src/styles/index.ts index 8551d8717e9d..7c69917bb011 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -790,6 +790,11 @@ const staticStyles = (theme: ThemeColors) => overflow: 'visible', }, + // The floating bar is laid over the bottom of a tab root screen, so the offline indicator sits above it. + floatingTabBarOfflineIndicator: { + marginBottom: variables.floatingTabBarHeight + variables.floatingTabBarBottomInset, + }, + // The gap between the floating bar and the bottom edge of the screen. floatingTabBarBottomInset: { paddingBottom: variables.floatingTabBarBottomInset, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index d5eb6116b771..02fa82cd4e6e 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -29,16 +29,13 @@ const avatarSizes = { avatarBorderWidthLarge: 4, } as const; -const floatingTabBarHeight = 60; -const floatingTabBarBottomInset = 8; - export default { bottomTabHeight: 72, - floatingTabBarHeight, + floatingTabBarHeight: 60, floatingTabBarHorizontalInset: 16, // How far the selected pill reaches past its tab on each side, whatever the label's length. floatingTabBarSelectedOverhang: 4, - floatingTabBarBottomInset, + floatingTabBarBottomInset: 8, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). debugTabViewHeight: 64, contentHeaderHeight: getValueUsingPixelRatio(72, 100), From b62774122e5cb7114e7746176d8262e639e9fe57 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 7 Oct 2026 15:07:16 +0200 Subject: [PATCH 317/781] chore: bump bottom-tabs to 7.18.16 and native to 7.3.16, the newest without core 7.21.13 typing getRootState as possibly undefined --- package-lock.json | 40 ++++++++++++------- package.json | 5 ++- ...-failing-jest-by-disabling-esmodule.patch} | 0 ...2+active-indicator-color-precedence.patch} | 0 ...m-tabs+7.18.16+003+hidden-tab-items.patch} | 22 +++++----- ...-failing-jest-by-disabling-esmodule.patch} | 0 ...avigation+native+7.3.16+001+initial.patch} | 39 +++++++++--------- ...-failing-jest-by-disabling-esmodule.patch} | 0 ...istory-go-popstate-fallback-timeout.patch} | 8 ++-- patches/react-navigation/details.md | 15 +++---- 10 files changed, 71 insertions(+), 58 deletions(-) rename patches/react-navigation/{@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch => @react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch} (100%) rename patches/react-navigation/{@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch => @react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch} (92%) rename patches/react-navigation/{@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+native+7.2.6+001+initial.patch => @react-navigation+native+7.3.16+001+initial.patch} (86%) rename patches/react-navigation/{@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch => @react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch} (100%) rename patches/react-navigation/{@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch => @react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch} (87%) diff --git a/package-lock.json b/package-lock.json index 57c19f91599b..032b736a5426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,10 +45,10 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", - "@react-navigation/bottom-tabs": "7.16.2", + "@react-navigation/bottom-tabs": "7.18.16", "@react-navigation/core": "7.21.12", "@react-navigation/material-top-tabs": "7.4.19", - "@react-navigation/native": "7.2.6", + "@react-navigation/native": "7.3.16", "@react-navigation/native-stack": "7.14.5", "@react-navigation/stack": "7.8.5", "@react-ng/bounds-observer": "^0.2.1", @@ -15105,17 +15105,17 @@ } }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.16.2", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.16.2.tgz", - "integrity": "sha512-Lbp++BGMc7SQXnyKuO/JrQJIhFH0zyB5v4kIEbnzDJLJfgubd5hoSe+QfCqy4YHfLA4phC4Xf/6Q2Ic8x7datQ==", + "version": "7.18.16", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.18.16.tgz", + "integrity": "sha512-5tBFuxvsCQBvYauDZK6u/uPdD86i/caWgBzABlZfTh8W2J7YZSHjT9SWOsquSMmR6dpcNj6idMd4utCNzuOnZg==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.19", + "@react-navigation/elements": "^2.9.38", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.2.5", + "@react-navigation/native": "^7.3.16", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -15163,9 +15163,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.19.tgz", - "integrity": "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A==", + "version": "2.9.38", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.38.tgz", + "integrity": "sha512-M/2HWDiSO4zO7VyfAnovrTPWOAzTC+1DnVT7ZWZW93+/KVRF9OYlqixIUpPenQ0iQh2j1EaQ4zOe/134Arisqw==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -15174,7 +15174,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.2.5", + "@react-navigation/native": "^7.3.16", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -15204,15 +15204,16 @@ } }, "node_modules/@react-navigation/native": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.6.tgz", - "integrity": "sha512-Lvxb8ShLnsuK1pjFoN1jym/8c68IvJQlkrDhQXDJq10o3lU36Oxsmc9BdtN+Z4yJRYSbx3Qh+Wpom75QehC4og==", + "version": "7.3.16", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.16.tgz", + "integrity": "sha512-Wy/6mai2HpA5AEVFk7JFfvv4RUArwuN2UenP/fc3NK7kOyfSlNu1tmb/3JDPpMzPIRbF1MPB8PKAHjVB29qNEQ==", "license": "MIT", "dependencies": { - "@react-navigation/core": "^7.18.0", + "@react-navigation/core": "^7.21.12", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", + "standard-navigation": "^0.0.8", "use-latest-callback": "^0.2.4" }, "peerDependencies": { @@ -40264,6 +40265,15 @@ "node": ">=8" } }, + "node_modules/standard-navigation": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.8.tgz", + "integrity": "sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/statuses": { "version": "2.0.1", "license": "MIT", diff --git a/package.json b/package.json index 4b2b5480cf40..afe4c68bf06b 100644 --- a/package.json +++ b/package.json @@ -121,10 +121,10 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", - "@react-navigation/bottom-tabs": "7.16.2", + "@react-navigation/bottom-tabs": "7.18.16", "@react-navigation/core": "7.21.12", "@react-navigation/material-top-tabs": "7.4.19", - "@react-navigation/native": "7.2.6", + "@react-navigation/native": "7.3.16", "@react-navigation/native-stack": "7.14.5", "@react-navigation/stack": "7.8.5", "@react-ng/bounds-observer": "^0.2.1", @@ -418,6 +418,7 @@ "node-abi": "^3.78.0", "pdfjs-dist": "^5.5.207", "lightningcss": "1.30.1", + "@react-navigation/elements": "2.9.38", "@fullstory/react-native": { "expo": "57.0.8" }, diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch similarity index 100% rename from patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch rename to patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch similarity index 92% rename from patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch rename to patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch index 0363ebe03089..9dd8d44e6bd1 100644 --- a/patches/react-navigation/@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js -index 09ec68f..6645270 100644 +index e6ea55b..4d577fe 100644 --- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js +++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js @@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac @@ -59,9 +59,9 @@ index 09ec68f..6645270 100644 - tabBarHidden: shouldHideTabBar, + tabBarHidden: shouldHideTabBar || isFocusedRouteHidden, colorScheme: dark ? 'dark' : 'light', - ios: { - bottomAccessory: bottomAccessory ? environment => bottomAccessory({ -@@ -210,6 +222,9 @@ export function NativeBottomTabView({ + nativeContainerStyle: { + backgroundColor: colors.background +@@ -213,6 +225,9 @@ export function NativeBottomTabView({ tabBarTintColor: activeTintColor }, children: state.routes.map((route, index) => { @@ -71,7 +71,7 @@ index 09ec68f..6645270 100644 const { options, render, -@@ -221,6 +236,7 @@ export function NativeBottomTabView({ +@@ -224,6 +239,7 @@ export function NativeBottomTabView({ title, lazy = false, tabBarLabel, @@ -79,15 +79,15 @@ index 09ec68f..6645270 100644 tabBarSelectionEnabled, tabBarBadgeStyle, tabBarIcon, -@@ -287,6 +303,7 @@ export function NativeBottomTabView({ +@@ -290,6 +306,7 @@ export function NativeBottomTabView({ preventNativeSelection: tabBarSelectionEnabled === false, badgeValue: tabBarBadge?.toString(), title: tabTitle, + tabBarItemAccessibilityLabel: tabBarAccessibilityLabel, - specialEffects: { - repeatedTabSelection: { - popToRoot: true, -@@ -363,9 +380,40 @@ export function NativeBottomTabView({ + nativeContainerStyle: { + backgroundColor: colors.background + }, +@@ -369,9 +386,40 @@ export function NativeBottomTabView({ }) }, route.key); }) @@ -130,7 +130,7 @@ index 09ec68f..6645270 100644 isFocused, route, diff --git a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts -index 5a502c2..a2f2e85 100644 +index ac25ed1..c05b04b 100644 --- a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts +++ b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts @@ -78,6 +78,15 @@ type IconIOSSfSymbol = { diff --git a/patches/react-navigation/@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+native+7.2.6+001+initial.patch b/patches/react-navigation/@react-navigation+native+7.3.16+001+initial.patch similarity index 86% rename from patches/react-navigation/@react-navigation+native+7.2.6+001+initial.patch rename to patches/react-navigation/@react-navigation+native+7.3.16+001+initial.patch index 36dc2bcd4e8c..123cb28cce74 100644 --- a/patches/react-navigation/@react-navigation+native+7.2.6+001+initial.patch +++ b/patches/react-navigation/@react-navigation+native+7.3.16+001+initial.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -index 2ca4c08..0c204b9 100644 +index 58a543b..c475462 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js @@ -1,6 +1,7 @@ @@ -7,7 +7,7 @@ index 2ca4c08..0c204b9 100644 import { nanoid } from 'nanoid/non-secure'; +import { findFocusedRouteKey } from "./findFocusedRouteKey.js"; - const getPathWithoutHash = path => path.split('#')[0]; + const getPathWithoutHash = path => path.split('#')[0] ?? ''; export function createMemoryHistory() { let index = 0; @@ -19,6 +20,9 @@ export function createMemoryHistory() { @@ -20,7 +20,7 @@ index 2ca4c08..0c204b9 100644 get index() { // We store an id in the state instead of an index // Index could get out of sync with in-memory values if page reloads -@@ -33,14 +37,15 @@ export function createMemoryHistory() { +@@ -33,7 +37,8 @@ export function createMemoryHistory() { return items[index]; }, backIndex({ @@ -30,15 +30,16 @@ index 2ca4c08..0c204b9 100644 }) { const pathWithoutHash = getPathWithoutHash(path); - // We need to find the index from the element before current to get closest path to go back to - for (let i = index - 1; i >= 0; i--) { - const item = items[i]; +@@ -43,7 +48,7 @@ export function createMemoryHistory() { + if (item == null) { + continue; + } - if (item.path === pathWithoutHash) { + if (item.path === pathWithoutHash && findFocusedRouteKey(item.state) === findFocusedRouteKey(state)) { return i; } } -@@ -82,7 +87,7 @@ export function createMemoryHistory() { +@@ -85,7 +90,7 @@ export function createMemoryHistory() { // Need to keep the hash part of the path if there was no previous history entry // or the previous history entry had the same path @@ -62,18 +63,18 @@ index 0000000..290e96f +} +//# sourceMappingURL=findFocusedRouteKey.js.map diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js -index 6005f78..a932e78 100644 +index 66b73b5..6c201fd 100644 --- a/node_modules/@react-navigation/native/lib/module/useLinking.js +++ b/node_modules/@react-navigation/native/lib/module/useLinking.js -@@ -4,6 +4,7 @@ import { findFocusedRoute, getActionFromState as getActionFromStateDefault, getP +@@ -4,6 +4,7 @@ import { CommonActions, findFocusedRoute, getActionFromState as getActionFromSta import isEqual from 'fast-deep-equal'; import * as React from 'react'; import { createMemoryHistory } from "./createMemoryHistory.js"; +import { findFocusedRouteKey } from "./findFocusedRouteKey.js"; import { ServerContext } from "./ServerContext.js"; - const getRoutesUntilIndex = state => state.routes.slice(0, state.index + 1); -@@ -65,6 +66,43 @@ export const series = cb => { + /** +@@ -114,6 +115,43 @@ export const series = cb => { return callback; }; const linkingHandlers = []; @@ -117,17 +118,17 @@ index 6005f78..a932e78 100644 export function useLinking(ref, { enabled = true, config, -@@ -298,6 +336,9 @@ export function useLinking(ref, { - // Otherwise it's likely a change triggered by `popstate` - path !== pendingPath) { - const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState); +@@ -409,6 +447,9 @@ export function useLinking(ref, { + const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState) - ( + // Subtract the delta already applied by the browser to sync only the remaining changes + pendingPopStateDelta ?? 0); + + // The historyDelta and historyDeltaByKeys may differ if the new state has an entry that didn't exist in previous state -+ const historyDeltaByKeys = getHistoryDeltaByKeys(focusedState, previousFocusedState); ++ const historyDeltaByKeys = getHistoryDeltaByKeys(focusedState, previousFocusedState) - (pendingPopStateDelta ?? 0); if (historyDelta > 0) { // If history length is increased, we should pushState // Note that path might not actually change here, for example, drawer open should pushState -@@ -309,7 +350,8 @@ export function useLinking(ref, { +@@ -420,7 +461,8 @@ export function useLinking(ref, { // If history length is decreased, i.e. entries were removed, we want to go back const nextIndex = history.backIndex({ @@ -137,7 +138,7 @@ index 6005f78..a932e78 100644 }); const currentIndex = history.index; try { -@@ -318,11 +360,26 @@ export function useLinking(ref, { +@@ -429,11 +471,26 @@ export function useLinking(ref, { history.get(nextIndex)) { // An existing entry for this path exists and it's less than current index, go back to that await history.go(nextIndex - currentIndex); @@ -165,7 +166,7 @@ index 6005f78..a932e78 100644 } // Store the updated state as well as fix the path if incorrect -@@ -335,10 +392,22 @@ export function useLinking(ref, { +@@ -446,10 +503,22 @@ export function useLinking(ref, { } } else { // If history length is unchanged, we want to replaceState diff --git a/patches/react-navigation/@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch b/patches/react-navigation/@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch similarity index 87% rename from patches/react-navigation/@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch rename to patches/react-navigation/@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch index bda764b7f963..062073b6a427 100644 --- a/patches/react-navigation/@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch +++ b/patches/react-navigation/@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -index 07d02ef..3f7bc78 100644 +index c475462..f8e7d1c 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -@@ -178,10 +178,14 @@ export function createMemoryHistory() { +@@ -188,10 +188,14 @@ export function createMemoryHistory() { cb: done }); @@ -18,8 +18,8 @@ index 07d02ef..3f7bc78 100644 + // back/forward navigation and reset the app to a stale route // We're using a hacky timeout since there doesn't seem to be way to know for sure const timer = setTimeout(() => { - const foundIndex = pending.findIndex(it => it.ref === done); -@@ -190,7 +194,7 @@ export function createMemoryHistory() { + window.removeEventListener('popstate', onPopState); +@@ -201,7 +205,7 @@ export function createMemoryHistory() { pending.splice(foundIndex, 1); } index = this.index; diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index b186911582fb..43ccd25ca5ae 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -1,12 +1,12 @@ # `@react-navigation` patches ### @react-navigation+package-name+7+fix-failing-jest-by-disabling-esmodule.patch -#### [@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.16.2+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.18.16+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+core+7.21.12+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.19+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.38+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.2.6+002+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.3.16+002+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+routers+7.6.4+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+stack+7.8.5+004+fix-failing-jest-by-disabling-esmodule.patch) @@ -24,7 +24,7 @@ - PR Introducing Patch: [#37891](https://github.com/Expensify/App/pull/37891) - PR Updating Patch: [#64155](https://github.com/Expensify/App/pull/64155), [#98097](https://github.com/Expensify/App/pull/98097) -### [@react-navigation+native+7.2.6+001+initial.patch](@react-navigation+native+7.2.6+001+initial.patch) +### [@react-navigation+native+7.3.16+001+initial.patch](@react-navigation+native+7.3.16+001+initial.patch) - Reason: Allows us to use some more advanced navigation actions without messing up the browser history - Upstream PR/issue: https://github.com/react-navigation/react-navigation/pull/12751 (`route.history` + `pushParams`, added upstream for this use case); originating issue https://github.com/react-navigation/react-navigation/issues/12460. On the v8 upgrade, evaluate adopting `pushParams` to shrink this patch. @@ -49,7 +49,7 @@ - PR Updating Patch: [#33280](https://github.com/Expensify/App/pull/33280) [#37421](https://github.com/Expensify/App/pull/37421) [#49539](https://github.com/Expensify/App/pull/49539) [#64155](https://github.com/Expensify/App/pull/64155) [#65119](https://github.com/Expensify/App/issues/65119) - Note: Not fully covered by the public `detachPreviousScreen` option (this also forces `activityState`). v8 replaces `detachInactiveScreens`/`detachPreviousScreen`/`freezeOnBlur` with a single `inactiveBehavior` option — re-evaluate this patch then. -### [@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch](@react-navigation+native+7.2.6+003+increase-history-go-popstate-fallback-timeout.patch) +### [@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch](@react-navigation+native+7.3.16+003+increase-history-go-popstate-fallback-timeout.patch) - Reason: @@ -68,8 +68,9 @@ - E/App issue: [#94571](https://github.com/Expensify/App/issues/94571) - PR Introducing Patch: [#95980](https://github.com/Expensify/App/pull/95980) - PR Updating Patch: [#101339](https://github.com/Expensify/App/pull/101339) +- Note: Upstream raised the timeout to 1000ms in `@react-navigation/native` 7.5.0, so this patch can be dropped on the bump to 7.5.0 or newer. -### [@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.16.2+002+active-indicator-color-precedence.patch) +### [@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.18.16+002+active-indicator-color-precedence.patch) - Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. **This patch can be removed on the upgrade to React Navigation v8**, whose rewritten native tab view no longer has the bug. - Upstream PR/issue: not reported, because the v8 branch already fixes it and the fix only matters until the App moves to v8. Still present on the `7.x` branch as of 7.20.0. @@ -77,7 +78,7 @@ - PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339) - PR Updating Patch: N/A -### [@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.16.2+003+hidden-tab-items.patch) +### [@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.18.16+003+hidden-tab-items.patch) - Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The host's selection is derived from the tab history, skipping routes that are hidden by then, so a beta change that hides the selected route cannot leave the host pointing at a removed tab. A route that moves between hidden and visible is remounted, and a hidden route ignores preloading and emits no transition events. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays. - Upstream PR/issue: not reported, because hiding an item from a native tab bar has no native counterpart and this works around it in JS for the App's tab set; the accessibility label part is already in v8. From c1aabf4f82aec5fef8979439ccf25d065ede60df Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 7 Oct 2026 15:28:32 +0200 Subject: [PATCH 318/781] fix: iOS offline indicator sits above the native tab bar on every tab root, measured from the screen bottom --- .../useTabRootScreenWrapperProps/index.ios.tsx | 11 +++++++++-- src/styles/index.ts | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx index c7d86f32d2c4..4af99cca3d8c 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -1,14 +1,21 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getFloatingButtonsBottom} from '@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions'; import type TabRootScreenWrapperProps from './types'; /** * On narrow layouts the content of a tab root screen runs under the translucent UITabBar, down to the bottom edge of - * the screen, so the ScreenWrapper adds no bottom padding. Each tab root's list lets UIKit inset its end past the bar. + * the screen, so the ScreenWrapper adds no bottom padding. Each tab root's list lets UIKit inset its end past the bar, + * and the offline indicator sits above the bar. */ function useTabRootScreenWrapperProps(): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); - return shouldUseNarrowLayout ? {includeSafeAreaPaddingBottom: false} : {}; + const styles = useThemeStyles(); + const {bottom: bottomInset} = useSafeAreaInsets(); + return shouldUseNarrowLayout ? {includeSafeAreaPaddingBottom: false, offlineIndicatorStyle: styles.iosNativeTabBarOfflineIndicator(getFloatingButtonsBottom(bottomInset))} : {}; } export default useTabRootScreenWrapperProps; diff --git a/src/styles/index.ts b/src/styles/index.ts index 7c69917bb011..d6a644d1597a 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6978,6 +6978,16 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ + // Measured from the bottom of the screen, because only some tab roots pad the indicator's container with the inset. + iosNativeTabBarOfflineIndicator: (bottom: number) => + ({ + position: 'absolute', + right: 0, + bottom, + left: 0, + backgroundColor: theme.appBG, + }) satisfies ViewStyle, + // Zero-height strip anchored above the native tab bar, so the floating buttons can be positioned // against it without taking part in the tab screen's layout. nativeTabBarFloatingButtons: (bottom: number) => From 4457e4b55a71f4c8926d603925dabbc02961b74d Mon Sep 17 00:00:00 2001 From: Olgierd Date: Wed, 7 Oct 2026 16:06:18 +0200 Subject: [PATCH 319/781] Show tooltips on mobile devices --- .../Charts/PieChart/PieChartContent.tsx | 27 +++++++- .../Charts/hooks/useChartInteractions.ts | 68 ++++++++++++++++--- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index 180542eea001..b6cd81456bd1 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -14,7 +14,7 @@ import type {LayoutChangeEvent} from 'react-native'; import React, {useState} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {Gesture, GestureDetector, PointerType} from 'react-native-gesture-handler'; import Animated, {useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import {Pie, PolarChart} from 'victory-native'; @@ -91,12 +91,21 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice } }; + const toggleActiveSlice = (x: number, y: number) => { + const {centerX, centerY} = pieGeometry; + const sliceIndex = findSliceAtPosition(x, y, centerX, centerY, radius, innerRadius, processedSlices); + setActiveSliceIndex(sliceIndex === activeSliceIndex ? -1 : sliceIndex); + }; + // Hover gesture const hoverGesture = () => Gesture.Hover() .onBegin((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } isHovering.set(true); cursorX.set(e.x); cursorY.set(e.y); @@ -106,24 +115,36 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice .onUpdate((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } cursorX.set(e.x); cursorY.set(e.y); tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP}); scheduleOnRN(updateActiveSlice, e.x, e.y); }) - .onEnd(() => { + .onEnd((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } isHovering.set(false); scheduleOnRN(setActiveSliceIndex, -1); scheduleOnRN(setIsHoveringOverPie, false); }); - // Tap gesture for click/tap navigation + // Tap gesture: touch toggles the tooltip, other pointers navigate on click const tapGesture = () => Gesture.Tap().onEnd((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP}); + scheduleOnRN(toggleActiveSlice, e.x, e.y); + return; + } + const {centerX, centerY} = pieGeometry; const sliceIndex = findSliceAtPosition(e.x, e.y, centerX, centerY, radius, innerRadius, processedSlices); diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 2be8c37384b5..c698f9570eeb 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -1,7 +1,7 @@ import type {SharedValue} from 'react-native-reanimated'; import {useCallback} from 'react'; -import {Gesture} from 'react-native-gesture-handler'; +import {Gesture, PointerType} from 'react-native-gesture-handler'; import {useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -184,6 +184,9 @@ function useChartInteractions({ const isCursorOverClickable = useSharedValue(false); const isTooltipActive = useSharedValue(false); + /** True while a tooltip opened by a touch tap stays visible until the next tap */ + const isTouchPinned = useSharedValue(false); + /** * Called by chart content from handleScaleChange to populate canvas positions. * Must be called with the positions derived from the current d3 scale. @@ -305,11 +308,26 @@ function useChartInteractions({ * customGestures prop, because Victory's internal GestureHandler view only covers * the plot area and would drop events from the label area. */ + const hideTooltip = () => { + 'worklet'; + + chartInteractionState.isActive.set(false); + isTouchPinned.set(false); + isCursorOverTarget.set(false); + isCursorOverClickable.set(false); + isTooltipActive.set(false); + }; + const hoverGesture = () => Gesture.Hover() .onBegin((e) => { 'worklet'; + // Touch on web can start a hover through pointerenter, which would fight the tap-to-show tooltip + if (e.pointerType === PointerType.TOUCH) { + return; + } + isTouchPinned.set(false); const cursorX = normalizeChartCoordinate(e.x, coordinateScale); const cursorY = normalizeChartCoordinate(e.y, coordinateScale); chartInteractionState.isActive.set(true); @@ -324,6 +342,9 @@ function useChartInteractions({ .onUpdate((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } const cursorX = normalizeChartCoordinate(e.x, coordinateScale); const cursorY = normalizeChartCoordinate(e.y, coordinateScale); chartInteractionState.cursor.x.set(cursorX); @@ -340,18 +361,45 @@ function useChartInteractions({ updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); } }) - .onEnd(() => { + .onEnd((e) => { 'worklet'; - chartInteractionState.isActive.set(false); - isCursorOverTarget.set(false); - isCursorOverClickable.set(false); - isTooltipActive.set(false); + if (e.pointerType === PointerType.TOUCH) { + return; + } + hideTooltip(); }); /** - * Tap gesture. Resolves the nearest data point entirely on the UI thread, - * then schedules handlePress on the JS thread if the cursor is over the target. + * Touch taps toggle the tooltip instead of drilling in, since touch devices have no hover. + * Tapping a new target shows its tooltip; tapping the same target again or empty space hides it. + */ + const handleTouchTap = (cursorX: number, cursorY: number) => { + 'worklet'; + + const previousIndex = chartInteractionState.matchedIndex.get(); + const wasPinned = isTouchPinned.get(); + const bottom = chartBottom?.get() ?? cursorY; + const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; + const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); + if (targetIndex < 0 || (wasPinned && targetIndex === previousIndex)) { + hideTooltip(); + return; + } + + chartInteractionState.isActive.set(true); + applyTargetIndex(targetIndex); + const isOverTarget = updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); + if (!isOverTarget) { + hideTooltip(); + return; + } + isTouchPinned.set(true); + }; + + /** + * Tap gesture. Resolves the nearest data point entirely on the UI thread. + * Touch taps toggle the tooltip; other pointers schedule handlePress on the JS thread if the cursor is over the target. */ const tapGesture = () => Gesture.Tap().onEnd((e) => { @@ -361,6 +409,10 @@ function useChartInteractions({ const cursorY = normalizeChartCoordinate(e.y, coordinateScale); chartInteractionState.cursor.x.set(cursorX); chartInteractionState.cursor.y.set(cursorY); + if (e.pointerType === PointerType.TOUCH) { + handleTouchTap(cursorX, cursorY); + return; + } const ox = pointOX.get(); const oy = pointOY.get(); const idx = getResolvedTargetIndex(cursorX, cursorY, cursorX); From a4481c10bd06a437ff13f03ec1a32683f09b8c29 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 7 Oct 2026 18:26:22 +0200 Subject: [PATCH 320/781] prevent same-name offices --- src/languages/de.ts | 3 ++- src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + .../API/parameters/AddOfficeLocationParams.ts | 2 +- .../API/parameters/UpdateOfficeLocationParams.ts | 2 +- src/libs/actions/Policy/DistanceRate.ts | 4 ++-- .../workspace/WorkspaceOfficeLocationAddPage.tsx | 13 +++++++++---- .../WorkspaceOfficeLocationEditPage.tsx | 16 ++++++++++++---- .../workspace/WorkspaceOfficeLocationForm.tsx | 8 +++++++- tests/actions/PolicyOfficeLocationTest.ts | 4 ++-- 18 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 8493603bfe60..cc0034f4c4e9 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7717,7 +7717,8 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, setAsPrimary: 'Als Hauptstandort festlegen', primary: 'Hauptstandort', deleteOfficeLocation: 'Bürostandort löschen', - deleteOfficeLocationConfirmation: 'Möchten Sie diesen Bürostandort wirklich löschen?', + deleteOfficeLocationConfirmation: 'Möchtest du diesen Bürostandort wirklich löschen?', + existingOfficeLocationError: 'Ein Bürostandort mit diesem Namen existiert bereits', }, bankAccount: { continueWithSetup: 'Einrichtung fortsetzen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 586dfcfa304e..14523cbb619e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8011,6 +8011,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ primary: 'Κύρια', deleteOfficeLocation: 'Διαγραφή τοποθεσίας γραφείου', deleteOfficeLocationConfirmation: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή την τοποθεσία γραφείου;', + existingOfficeLocationError: 'Υπάρχει ήδη τοποθεσία γραφείου με αυτό το όνομα', }, bankAccount: { continueWithSetup: 'Συνεχίστε τη ρύθμιση', diff --git a/src/languages/en.ts b/src/languages/en.ts index 855380b97627..05b074bd6dcc 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8229,6 +8229,7 @@ const translations = { primary: 'Primary', deleteOfficeLocation: 'Delete office location', deleteOfficeLocationConfirmation: 'Are you sure you want to delete this office location?', + existingOfficeLocationError: 'An office location with this name already exists', }, bankAccount: { continueWithSetup: 'Continue setup', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1ad8e38e72c7..663374bcd2e5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7737,6 +7737,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, primary: 'Principal', deleteOfficeLocation: 'Eliminar ubicación de oficina', deleteOfficeLocationConfirmation: '¿Estás seguro de que quieres eliminar esta ubicación de oficina?', + existingOfficeLocationError: 'Ya existe una ubicación de oficina con este nombre', }, bankAccount: { continueWithSetup: 'Continuar con la configuración', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1e5b36654de7..b8bc3c41a150 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7737,6 +7737,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, primary: 'Principal', deleteOfficeLocation: 'Supprimer le lieu de travail', deleteOfficeLocationConfirmation: 'Voulez-vous vraiment supprimer ce lieu de travail ?', + existingOfficeLocationError: 'Un lieu de travail portant ce nom existe déjà', }, bankAccount: { continueWithSetup: 'Continuer la configuration', diff --git a/src/languages/it.ts b/src/languages/it.ts index 1baacf938a53..5019ca4693c1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7674,6 +7674,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, primary: 'Principale', deleteOfficeLocation: 'Elimina sede dell’ufficio', deleteOfficeLocationConfirmation: 'Sei sicuro di voler eliminare questa sede dell’ufficio?', + existingOfficeLocationError: 'Esiste già una sede dell’ufficio con questo nome', }, bankAccount: { continueWithSetup: 'Continua configurazione', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index e40c802c40b1..9879c39c2c15 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7581,6 +7581,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から primary: 'メイン', deleteOfficeLocation: 'オフィスの所在地を削除', deleteOfficeLocationConfirmation: 'このオフィスの所在地を削除してもよろしいですか?', + existingOfficeLocationError: 'この名前のオフィスの所在地はすでに存在します', }, bankAccount: { continueWithSetup: 'セットアップを続行', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 214afe08a6ea..aab914274408 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7655,6 +7655,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, primary: 'Primair', deleteOfficeLocation: 'Kantoorlocatie verwijderen', deleteOfficeLocationConfirmation: 'Weet je zeker dat je deze kantoorlocatie wilt verwijderen?', + existingOfficeLocationError: 'Er bestaat al een kantoorlocatie met deze naam', }, bankAccount: { continueWithSetup: 'Setup voortzetten', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 710189cda337..a69697b7352d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7677,6 +7677,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, primary: 'Główna', deleteOfficeLocation: 'Usuń lokalizację biura', deleteOfficeLocationConfirmation: 'Czy na pewno chcesz usunąć tę lokalizację biura?', + existingOfficeLocationError: 'Lokalizacja biura o tej nazwie już istnieje', }, bankAccount: { continueWithSetup: 'Kontynuuj konfigurację', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 175834b68a1b..c0325c9cc111 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7660,6 +7660,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, primary: 'Principal', deleteOfficeLocation: 'Excluir local de escritório', deleteOfficeLocationConfirmation: 'Tem certeza de que deseja excluir este local de escritório?', + existingOfficeLocationError: 'Já existe um local de escritório com este nome', }, bankAccount: { continueWithSetup: 'Continuar configuração', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c3febccb23cc..451904e9acba 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7396,6 +7396,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM primary: '主要', deleteOfficeLocation: '删除办公地点', deleteOfficeLocationConfirmation: '确定要删除此办公地点吗?', + existingOfficeLocationError: '已存在同名的办公地点', }, bankAccount: { continueWithSetup: '继续设置', diff --git a/src/libs/API/parameters/AddOfficeLocationParams.ts b/src/libs/API/parameters/AddOfficeLocationParams.ts index e5198d8b5748..d436044b5fbd 100644 --- a/src/libs/API/parameters/AddOfficeLocationParams.ts +++ b/src/libs/API/parameters/AddOfficeLocationParams.ts @@ -8,7 +8,7 @@ type AddOfficeLocationParams = { address: string; /** Nickname of the office. Offices added without one are named "Office location N" */ - name?: string; + officeName?: string; /** Whether the office becomes the workspace default */ isDefault?: boolean; diff --git a/src/libs/API/parameters/UpdateOfficeLocationParams.ts b/src/libs/API/parameters/UpdateOfficeLocationParams.ts index ba9c676f7bae..f0668f7b8ec4 100644 --- a/src/libs/API/parameters/UpdateOfficeLocationParams.ts +++ b/src/libs/API/parameters/UpdateOfficeLocationParams.ts @@ -3,7 +3,7 @@ type UpdateOfficeLocationParams = { officeID: string; /** New nickname of the office */ - name?: string; + officeName?: string; /** JSON-encoded address with addressStreet, city, state, zipCode and country, the same shape as the workspace address */ address?: string; diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index aa88eabe1442..c27d7d69daa1 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -835,7 +835,7 @@ function addOfficeLocation(policyID: string, officeLocations: Record officeLocation.name), ...(isCompanyAddressShown ? [translate('common.companyAddress')] : [])]; const addOffice = (name: string, address: CompanyAddress, isPrimary: boolean) => { // An office added without a name shows this one until the server names it the same way: numbered after the existing - // offices, skipping names already in use - const officeNames = new Set(savedOfficeLocations.map((officeLocation) => officeLocation.name)); + // offices, skipping names already in use regardless of case + const lowerCaseTakenNames = new Set(takenNames.map((takenName) => takenName.toLowerCase())); let officeNumber = officeCount + 1; - while (officeNames.has(translate('workspace.officeLocations.defaultName', {officeNumber}))) { + while (lowerCaseTakenNames.has(translate('workspace.officeLocations.defaultName', {officeNumber}).toLowerCase())) { officeNumber++; } addOfficeLocation(policyID, officeLocations, address, isPrimary, name, translate('workspace.officeLocations.defaultName', {officeNumber})); @@ -70,6 +74,7 @@ function WorkspaceOfficeLocationAddPage({route}: WorkspaceOfficeLocationAddPageP isPrimary={isOnlyOffice} isPrimaryLocked={isOnlyOffice} isNameRequired={false} + takenNames={takenNames} onSubmit={addOffice} /> diff --git a/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx b/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx index 13231f801a9a..f521b2c52cc8 100644 --- a/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx +++ b/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx @@ -41,12 +41,19 @@ function WorkspaceOfficeLocationEditPage({route}: WorkspaceOfficeLocationEditPag // The company address counts as an office, so only a workspace without one keeps at least one office and can't delete // its last office. Offices being deleted and additions that failed aren't on the server, so they don't count. - const savedOfficeCount = Object.values(officeLocations ?? {}).filter( - (otherOfficeLocation) => + const savedOfficeLocations = Object.entries(officeLocations ?? {}).filter( + ([, otherOfficeLocation]) => otherOfficeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !(otherOfficeLocation.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(otherOfficeLocation.errors)), - ).length; - const canDelete = savedOfficeCount > 1 || hasCompanyAddress(policy); + ); + const isCompanyAddressShown = hasCompanyAddress(policy); + const canDelete = savedOfficeLocations.length > 1 || isCompanyAddressShown; + + // The company address row is listed with the offices, so its label is taken too + const takenNames = [ + ...savedOfficeLocations.filter(([otherOfficeID]) => otherOfficeID !== officeID).map(([, otherOfficeLocation]) => otherOfficeLocation.name), + ...(isCompanyAddressShown ? [translate('common.companyAddress')] : []), + ]; const saveOffice = (name: string, address: CompanyAddress, isPrimary: boolean) => { if (!officeLocation) { @@ -104,6 +111,7 @@ function WorkspaceOfficeLocationEditPage({route}: WorkspaceOfficeLocationEditPag isPrimary={!!officeLocation?.isDefault} isPrimaryLocked={!!officeLocation?.isDefault} isNameRequired + takenNames={takenNames} onSubmit={saveOffice} /> diff --git a/src/pages/workspace/WorkspaceOfficeLocationForm.tsx b/src/pages/workspace/WorkspaceOfficeLocationForm.tsx index af5bc6b8cc48..02f2017297fd 100644 --- a/src/pages/workspace/WorkspaceOfficeLocationForm.tsx +++ b/src/pages/workspace/WorkspaceOfficeLocationForm.tsx @@ -31,12 +31,15 @@ type WorkspaceOfficeLocationFormProps = { /** Whether the location name can't be left empty */ isNameRequired: boolean; + /** Names of the other locations of the workspace, which the location name can't match regardless of case */ + takenNames: string[]; + /** Called with the trimmed name, the office address and whether the office is the primary one */ onSubmit: (name: string, address: CompanyAddress, isPrimary: boolean) => void; }; /** The location name, address and "Set as primary" fields shared by the pages that add and edit workspace offices */ -function WorkspaceOfficeLocationForm({officeLocation, isPrimary, isPrimaryLocked, isNameRequired, onSubmit}: WorkspaceOfficeLocationFormProps) { +function WorkspaceOfficeLocationForm({officeLocation, isPrimary, isPrimaryLocked, isNameRequired, takenNames, onSubmit}: WorkspaceOfficeLocationFormProps) { const {translate} = useLocalize(); const address = officeLocation?.address; const {streetLineOne, streetLineTwo} = getWorkspaceAddressStreetLines(address?.addressStreet, address?.addressStreet2); @@ -77,6 +80,9 @@ function WorkspaceOfficeLocationForm({officeLocation, isPrimary, isPrimaryLocked errors[INPUT_IDS.LOCATION_NAME] = translate('common.error.fieldRequired'); } else if (name.length > CONST.FORM_CHARACTER_LIMIT) { errors[INPUT_IDS.LOCATION_NAME] = translate('common.error.characterLimitExceedCounter', name.length, CONST.FORM_CHARACTER_LIMIT); + } else if (!!name && name !== officeLocation?.name && takenNames.some((takenName) => takenName.trim().toLowerCase() === name.toLowerCase())) { + // Like the server, only a changed name is checked, so an office whose name was already taken can still save other changes + errors[INPUT_IDS.LOCATION_NAME] = translate('workspace.officeLocations.existingOfficeLocationError'); } return errors; }; diff --git a/tests/actions/PolicyOfficeLocationTest.ts b/tests/actions/PolicyOfficeLocationTest.ts index 048d9ffe7da0..2f2c0ad79396 100644 --- a/tests/actions/PolicyOfficeLocationTest.ts +++ b/tests/actions/PolicyOfficeLocationTest.ts @@ -142,7 +142,7 @@ describe('actions/PolicyOfficeLocation', () => { // Then no name is sent, so the server names the office const params = getRequestParams(WRITE_COMMANDS.ADD_OFFICE_LOCATION); - expect(params.name).toBeUndefined(); + expect(params.officeName).toBeUndefined(); // And the office shows the default name meanwhile const officeLocations = (await getPolicy(policyID))?.officeLocations ?? {}; @@ -187,7 +187,7 @@ describe('actions/PolicyOfficeLocation', () => { // And only the changed fields are sent const params = getRequestParams(WRITE_COMMANDS.UPDATE_OFFICE_LOCATION); - expect(params.name).toBe('HQ'); + expect(params.officeName).toBe('HQ'); expect(JSON.parse(params.address)).toEqual(NEW_JERSEY_ADDRESS); expect(params.isDefault).toBeUndefined(); From 430af4dbe0fcea7812930710d227a400c7f2dc95 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:45:19 +0100 Subject: [PATCH 321/781] remove canvasProp --- patches/victory-native/details.md | 17 ----------------- .../components/VictoryChartCartesian.tsx | 6 +----- .../components/VictoryChartContent.tsx | 7 +------ .../components/VictoryChartExpandModal.tsx | 2 +- .../ExpandedChartBox.tsx | 1 - .../components/VictoryChartPolar.tsx | 6 +----- .../getStaticChartCanvasProps/index.native.ts | 6 ------ .../utils/getStaticChartCanvasProps/index.ts | 11 ----------- .../utils/getStaticChartCanvasProps/types.ts | 7 ------- 9 files changed, 4 insertions(+), 59 deletions(-) delete mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts delete mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts delete mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts diff --git a/patches/victory-native/details.md b/patches/victory-native/details.md index 8abd6d85a317..745fbd8ef8f2 100644 --- a/patches/victory-native/details.md +++ b/patches/victory-native/details.md @@ -27,20 +27,3 @@ - Upstream PR/issue: Not yet. - E/App issue: https://github.com/Expensify/App/issues/91883 - PR introducing patch: https://github.com/Expensify/App/pull/100186 - -// s77rt TODO verify if patch is needed, if not remove canvasProps prop -### [victory-native+41.21.0+002+canvas-props.patch](victory-native+41.21.0+002+canvas-props.patch) - -- Reason: - - ``` - Forwards a `canvasProps` prop from CartesianChart/PolarChart to the underlying Skia , so the - expanded (full-screen) chart can opt into Skia's `__destroyWebGLContextAfterRender` static renderer on - web. That renders the chart into a plain 2D canvas bitmap and releases the WebGL context, which keeps - the chart visible through the modal close animation without the WebGL white flash and without holding - a live GPU context per expanded chart. - ``` - -- Upstream PR/issue: Not yet. -- E/App issue: https://github.com/Expensify/App/issues/92969 -- PR introducing patch: https://github.com/Expensify/App/pull/97698 diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index 54669fc1fab9..2db299d94b2e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -29,9 +29,6 @@ type VictoryChartCartesianProps = { /** When true, renders without visible chrome (used for snapshots/tests) */ headless?: boolean; - /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ - shouldUseStaticCanvas?: boolean; - onRenderArgs?: (renderArgs: CartesianChartRenderArg) => void; }; @@ -39,7 +36,7 @@ type VictoryChartCartesianProps = { * Renders the CartesianChart with data, axes, and domain config drawn from context. * Labels and legend overlays are handled internally via `renderOutside`. */ -function VictoryChartCartesian({explicitSize, headless, shouldUseStaticCanvas, onRenderArgs}: VictoryChartCartesianProps) { +function VictoryChartCartesian({explicitSize, headless, onRenderArgs}: VictoryChartCartesianProps) { const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles, pixelScale} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -69,7 +66,6 @@ function VictoryChartCartesian({explicitSize, headless, shouldUseStaticCanvas, o domainPadding={domainPadding} padding={padding} {...getChartLayoutModeProps(explicitSize, headless)} - canvasProps={shouldUseStaticCanvas ? getStaticChartCanvasProps() : undefined} renderOutside={(renderArgs) => { const overlayContent = ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx index fbd751310a73..21904ab9e5ea 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx @@ -9,12 +9,9 @@ import VictoryChartPolar from './VictoryChartPolar'; type VictoryChartContentProps = { explicitSize?: {width: number; height: number}; headless?: boolean; - - /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ - shouldUseStaticCanvas?: boolean; }; -function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: VictoryChartContentProps) { +function VictoryChartContent({explicitSize, headless}: VictoryChartContentProps) { const {type} = useVictoryChartContext(); switch (type) { case CHART_TYPE.CARTESIAN: @@ -22,7 +19,6 @@ function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: Vi ); case CHART_TYPE.POLAR: @@ -30,7 +26,6 @@ function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: Vi ); default: diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index a104930fc5a0..10db813a50e9 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -103,7 +103,7 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr styles.overflowHidden, ]} > - + ))} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx index e17cbf07a196..5f4450d414f1 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx @@ -53,7 +53,6 @@ function ExpandedChartBox({width, height, clippedHeight, providerScale, backgrou diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx index 1e6c3891c7b8..b060f9c125be 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx @@ -21,15 +21,12 @@ import VictoryChartLegend from './VictoryChartLegend'; type VictoryChartPolarProps = { explicitSize?: {width: number; height: number}; headless?: boolean; - - /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ - shouldUseStaticCanvas?: boolean; }; /** * Renders the PolarChart with data drawn from context. */ -function VictoryChartPolar({explicitSize, headless, shouldUseStaticCanvas}: VictoryChartPolarProps) { +function VictoryChartPolar({explicitSize, headless}: VictoryChartPolarProps) { const {tnode, data, labelItems, legendItems, chartContentStyles} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -67,7 +64,6 @@ function VictoryChartPolar({explicitSize, headless, shouldUseStaticCanvas}: Vict valueKey={VALUE_KEY} colorKey={COLOR_KEY} {...getChartLayoutModeProps(explicitSize, headless)} - canvasProps={shouldUseStaticCanvas ? getStaticChartCanvasProps() : undefined} > {headless ? ( {chartContent} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts deleted file mode 100644 index a06c1d23f515..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type GetStaticChartCanvasProps from './types'; - -/** Native Skia views don't flash on re-composite, so the chart keeps its regular renderer. */ -const getStaticChartCanvasProps: GetStaticChartCanvasProps = () => undefined; - -export default getStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts deleted file mode 100644 index 8b898def5617..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type GetStaticChartCanvasProps from './types'; - -/** - * Web: renders the chart into a plain 2D canvas bitmap and releases the WebGL context right after - * drawing. A bitmap stays visible through the modal close animation (a live WebGL canvas flashes - * white when re-composited) and doesn't hold a GPU context per expanded chart. - */ -// eslint-disable-next-line @typescript-eslint/naming-convention -- prop name is defined by react-native-skia -const getStaticChartCanvasProps: GetStaticChartCanvasProps = () => ({__destroyWebGLContextAfterRender: true}); - -export default getStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts deleted file mode 100644 index 4d13cf87d8f2..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {CanvasProps} from '@shopify/react-native-skia'; - -type StaticChartCanvasProps = Pick | undefined; - -type GetStaticChartCanvasProps = () => StaticChartCanvasProps; - -export default GetStaticChartCanvasProps; From 67f6a72620e0f2674d37a6ee32df9412eed4a4cd Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:45:40 +0100 Subject: [PATCH 322/781] remove getStaticChartCanvasProps - fix ts --- .../VictoryChartRenderer/components/VictoryChartCartesian.tsx | 1 - .../VictoryChartRenderer/components/VictoryChartPolar.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index 2db299d94b2e..c411b025498a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -5,7 +5,6 @@ import type {CartesianChartData, YKey} from '@components/HTMLEngineProvider/HTML import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; import getChartLayoutModeProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartLayoutModeProps'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; -import getStaticChartCanvasProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps'; import resolveChartThemeColor from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; import useCurrentTimezone from '@hooks/useCurrentTimezone'; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx index b060f9c125be..c14a048ec76e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx @@ -4,7 +4,6 @@ import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRendere import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; import getChartLayoutModeProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartLayoutModeProps'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; -import getStaticChartCanvasProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps'; import useCurrentTimezone from '@hooks/useCurrentTimezone'; import useTheme from '@hooks/useTheme'; From aec44bd56188202e46af8ad6b30c86ea7a9e10b1 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:53:02 +0100 Subject: [PATCH 323/781] bump victory-native to 42.0.1 --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index f52335977834..3cb3050656b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -153,7 +153,7 @@ "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", "scheduler": "0.27.0", - "victory-native": "^41.21.0", + "victory-native": "^42.0.1", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { @@ -42704,9 +42704,9 @@ } }, "node_modules/victory-native": { - "version": "41.21.0", - "resolved": "https://registry.npmjs.org/victory-native/-/victory-native-41.21.0.tgz", - "integrity": "sha512-qyldgowkkiAWzN765sODzp4REh2MxBoex+DF5LerIpssTXpxGYEF21bIrhAoQJLDXiZ2rrCj1Dye+MNVYJd7vw==", + "version": "42.0.1", + "resolved": "https://registry.npmjs.org/victory-native/-/victory-native-42.0.1.tgz", + "integrity": "sha512-W3dNBMeRtZ8AQGBkV1PrYYKLFSstTy6uulmlJJFch4hvPX/R1xH1VJXzZopK6GGbcq/ejcr8WwntWwhqTo0lzA==", "license": "MIT", "dependencies": { "d3-scale": "^4.0.2", @@ -42716,11 +42716,11 @@ "react-fast-compare": "^3.2.2" }, "peerDependencies": { - "@shopify/react-native-skia": ">=1.2.3 <3.0.0", + "@shopify/react-native-skia": ">=2.6.0 <3.0.0", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", - "react-native-reanimated": ">=3.0.0" + "react-native-reanimated": ">=3.19.1" } }, "node_modules/vlq": { diff --git a/package.json b/package.json index 2ae91dcc2d03..e953f708321f 100644 --- a/package.json +++ b/package.json @@ -229,7 +229,7 @@ "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", "scheduler": "0.27.0", - "victory-native": "^41.21.0", + "victory-native": "^42.0.1", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { From aaa6113e96a972df5e28536f949ed65025fd1577 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:36:02 +0100 Subject: [PATCH 324/781] restore .github folder --- .../javascript/authorChecklist/index.js | 901 ++++++++++++----- .../javascript/awaitStagingDeploys/index.js | 901 ++++++++++++----- .../actions/javascript/bumpVersion/index.js | 901 ++++++++++++----- .../javascript/checkAndroidStatus/index.js | 903 +++++++++++++----- .../javascript/checkDeployBlockers/index.js | 901 ++++++++++++----- .../javascript/checkSVGCompression/index.js | 901 ++++++++++++----- .../javascript/failureNotifier/index.js | 901 ++++++++++++----- .../javascript/formatCodeCovComment/index.js | 901 ++++++++++++----- .../generateHelpPreviewComment/index.js | 901 ++++++++++++----- .../getAndroidRolloutPercentage/index.js | 903 +++++++++++++----- .../javascript/getArtifactInfo/index.js | 901 ++++++++++++----- .../getDeployPullRequestList/index.js | 901 ++++++++++++----- .../javascript/getPreviousVersion/index.js | 901 ++++++++++++----- .../javascript/getPullRequestDetails/index.js | 901 ++++++++++++----- .../getPullRequestIncrementalChanges/index.js | 901 ++++++++++++----- .../isAuthorizedContributor/index.js | 901 ++++++++++++----- .../isDeployChecklistLocked/index.js | 903 +++++++++++++----- .../markPullRequestsAsDeployed/index.js | 901 ++++++++++++----- .../javascript/postOrReplaceComment/index.js | 901 ++++++++++++----- .../javascript/proposalPoliceComment/index.js | 903 +++++++++++++----- .../reopenIssueWithComment/index.js | 901 ++++++++++++----- .../javascript/reviewerChecklist/index.js | 901 ++++++++++++----- .../validateReassureOutput/index.js | 901 ++++++++++++----- .../javascript/verifySignedCommits/index.js | 901 ++++++++++++----- .../verifyStagingBuildsSucceeded/index.js | 901 ++++++++++++----- .github/workflows/README.md | 1 - .github/workflows/deploy.yml | 2 - 27 files changed, 16829 insertions(+), 5707 deletions(-) diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index fc3426566448..03420b1771b7 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; - } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index 906a5fe20da8..00e2f735fcb4 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js index 479e50a322da..c2258fb0a82d 100644 --- a/.github/actions/javascript/bumpVersion/index.js +++ b/.github/actions/javascript/bumpVersion/index.js @@ -2312,10 +2312,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -2382,7 +2438,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -2394,6 +2454,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request.host !== null) { @@ -2503,13 +2566,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -6116,6 +6187,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -6162,6 +6234,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -6324,24 +6399,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -6361,6 +6467,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6404,7 +6514,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -6429,9 +6539,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -6440,6 +6551,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6565,6 +6680,7 @@ var require_client_h1 = __commonJS({ } request.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6608,12 +6724,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6628,7 +6751,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6636,9 +6762,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6687,7 +6814,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request) { @@ -6705,6 +6832,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6717,6 +6861,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6747,8 +6914,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6769,11 +6944,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request.aborted || request.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request.aborted) { return; } - util.errorRequest(client, request, err || new RequestAbortedError()); + if (request.completed) { + if (request.upgrade || request.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } + return; + } + util.errorRequest(client, request, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -7129,6 +7311,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -7189,6 +7372,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -7352,16 +7539,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request.aborted || request.completed) { + const abort = (error2) => { + if (request.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request, err); + if (request.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -7376,18 +7569,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request.onUpgradeError(error2); + }; + const onReady = () => { + try { request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7948,9 +8165,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8456,8 +8674,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8628,7 +8846,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8649,6 +8866,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8848,7 +9066,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8858,6 +9075,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -9298,6 +9516,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -9351,6 +9587,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -9360,6 +9597,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -9421,7 +9669,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9470,9 +9720,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9480,6 +9742,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9487,6 +9750,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9506,6 +9774,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9533,7 +9802,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -16332,14 +16601,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -16422,7 +16725,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16552,18 +16859,14 @@ var require_parse2 = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -17385,7 +17688,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17491,31 +17794,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17534,20 +17832,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17557,19 +17847,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17600,8 +17888,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17609,18 +17903,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17635,6 +17932,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17694,6 +17998,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17714,6 +18021,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17727,6 +18037,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17737,32 +18050,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17805,6 +18132,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -18016,8 +18363,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -18061,9 +18406,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -18247,7 +18589,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -18322,19 +18670,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -18413,6 +18748,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -18432,10 +18801,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18466,63 +18838,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18531,17 +18870,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18557,43 +18895,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18618,12 +18955,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/checkAndroidStatus/index.js b/.github/actions/javascript/checkAndroidStatus/index.js index cf13138d08ce..de9e0e53ebc4 100644 --- a/.github/actions/javascript/checkAndroidStatus/index.js +++ b/.github/actions/javascript/checkAndroidStatus/index.js @@ -1875,10 +1875,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1945,7 +2001,11 @@ var require_request = __commonJS({ } else if (typeof val[i2] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i2]}`); + const str = `${val[i2]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1957,6 +2017,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2066,13 +2129,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5679,6 +5750,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5725,6 +5797,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5887,24 +5962,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5924,6 +6030,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5967,7 +6077,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5992,9 +6102,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -6003,6 +6114,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6128,6 +6243,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6171,12 +6287,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6191,7 +6314,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6199,9 +6325,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6250,7 +6377,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6268,6 +6395,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6280,6 +6424,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6310,8 +6477,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6332,11 +6507,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6692,6 +6874,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline: pipeline2 } = __require("node:stream"); var util = require_util(); var { @@ -6752,6 +6935,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6915,16 +7102,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6939,18 +7132,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7511,9 +7728,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8019,8 +8237,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8191,7 +8409,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8212,6 +8429,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8411,7 +8629,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8421,6 +8638,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8861,6 +9079,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8914,6 +9150,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8923,6 +9160,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8984,7 +9232,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9033,9 +9283,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9043,6 +9305,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9050,6 +9313,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9069,6 +9337,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9096,7 +9365,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15895,14 +16164,48 @@ var require_util6 = __commonJS({ for (let i2 = 0; i2 < path.length; ++i2) { const code = path.charCodeAt(i2); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i2 = 0; i2 < domain.length; ++i2) { + const code = domain.charCodeAt(i2); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i2 - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15985,7 +16288,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16115,18 +16422,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16948,7 +17251,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17054,31 +17357,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17097,20 +17395,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17120,19 +17410,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17163,8 +17451,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17172,18 +17466,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17198,6 +17495,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17257,6 +17561,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17277,6 +17584,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17290,6 +17600,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower2; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17300,32 +17613,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17368,6 +17695,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17579,8 +17926,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17624,9 +17969,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17810,7 +18152,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17885,19 +18233,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17976,6 +18311,40 @@ var require_eventsource_stream = __commonJS({ var CR2 = 13; var COLON2 = 58; var SPACE2 = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] < 48 || buffer[i2] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i2 = 0; i2 < length; i2++) { + if (line[i2] !== field[i2]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17995,10 +18364,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18029,63 +18401,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF2) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF2) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18094,17 +18433,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18120,43 +18458,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE2) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18181,12 +18518,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i2 = this.lineChunkIndex; i2 <= this.chunkIndex; i2++) { + const chunk = this.chunks[i2]; + const start = i2 === this.lineChunkIndex ? this.linePos : 0; + const end = i2 === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/checkDeployBlockers/index.js b/.github/actions/javascript/checkDeployBlockers/index.js index 2ca391c52c00..c59c854097c1 100644 --- a/.github/actions/javascript/checkDeployBlockers/index.js +++ b/.github/actions/javascript/checkDeployBlockers/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/checkSVGCompression/index.js b/.github/actions/javascript/checkSVGCompression/index.js index 1bd2d9dfd701..cad1560febba 100644 --- a/.github/actions/javascript/checkSVGCompression/index.js +++ b/.github/actions/javascript/checkSVGCompression/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/failureNotifier/index.js b/.github/actions/javascript/failureNotifier/index.js index 7eedc830062e..208e6286e656 100644 --- a/.github/actions/javascript/failureNotifier/index.js +++ b/.github/actions/javascript/failureNotifier/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/formatCodeCovComment/index.js b/.github/actions/javascript/formatCodeCovComment/index.js index 133b8de4b0d2..9aac21e43b56 100644 --- a/.github/actions/javascript/formatCodeCovComment/index.js +++ b/.github/actions/javascript/formatCodeCovComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/generateHelpPreviewComment/index.js b/.github/actions/javascript/generateHelpPreviewComment/index.js index 4fffa145a897..222ea227535c 100644 --- a/.github/actions/javascript/generateHelpPreviewComment/index.js +++ b/.github/actions/javascript/generateHelpPreviewComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error) { + util.errorRequest(client, request2, error); + util.destroy(socket, error); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error || new RequestAbortedError()); + } + return; + } + error = error || new RequestAbortedError(); + util.errorRequest(client, request2, error); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error); } - util.destroy(body, err); + util.destroy(body, error); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error) { + stream.off("response", onResponse); + abort(error); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error, data) => { - if (error) { - failWebsocketConnection(this.ws, error.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error, data) => { + if (error) { + const code = error instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getAndroidRolloutPercentage/index.js b/.github/actions/javascript/getAndroidRolloutPercentage/index.js index e970b3649819..1abd092e27ea 100644 --- a/.github/actions/javascript/getAndroidRolloutPercentage/index.js +++ b/.github/actions/javascript/getAndroidRolloutPercentage/index.js @@ -1875,10 +1875,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1945,7 +2001,11 @@ var require_request = __commonJS({ } else if (typeof val[i2] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i2]}`); + const str = `${val[i2]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1957,6 +2017,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request.host !== null) { @@ -2066,13 +2129,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5679,6 +5750,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5725,6 +5797,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5887,24 +5962,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5924,6 +6030,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -5967,7 +6077,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5992,9 +6102,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request.onUpgrade(statusCode, headers, socket, statusText); + } catch (error) { + util.errorRequest(client, request, error); + util.destroy(socket, error); } client[kResume](); } @@ -6003,6 +6114,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6128,6 +6243,7 @@ var require_client_h1 = __commonJS({ } request.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6171,12 +6287,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6191,7 +6314,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6199,9 +6325,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6250,7 +6377,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request) { @@ -6268,6 +6395,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6280,6 +6424,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6310,8 +6477,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6332,11 +6507,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request.aborted || request.completed) { + clearIdleSocketValidation(socket); + const abort = (error) => { + if (request.aborted) { + return; + } + if (request.completed) { + if (request.upgrade || request.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request, err || new RequestAbortedError()); + util.errorRequest(client, request, error || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6692,6 +6874,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline: pipeline2 } = __require("node:stream"); var util = require_util(); var { @@ -6752,6 +6935,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6915,16 +7102,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request.aborted || request.completed) { + const abort = (error) => { + if (request.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request, err); + if (request.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error || new RequestAbortedError()); + } + return; + } + error = error || new RequestAbortedError(); + util.errorRequest(client, request, error); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error); } - util.destroy(body, err); + util.destroy(body, error); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6939,18 +7132,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request.onUpgradeError(error); + }; + const onReady = () => { + try { request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error) { + stream.off("response", onResponse); + abort(error); + return; + } + if (request.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7511,9 +7728,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8019,8 +8237,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8191,7 +8409,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8212,6 +8429,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8411,7 +8629,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8421,6 +8638,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8861,6 +9079,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8914,6 +9150,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8923,6 +9160,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8984,7 +9232,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9033,9 +9283,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9043,6 +9305,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9050,6 +9313,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9069,6 +9337,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9096,7 +9365,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15895,14 +16164,48 @@ var require_util6 = __commonJS({ for (let i2 = 0; i2 < path.length; ++i2) { const code = path.charCodeAt(i2); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i2 = 0; i2 < domain.length; ++i2) { + const code = domain.charCodeAt(i2); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i2 - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15985,7 +16288,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16115,18 +16422,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; - } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16948,7 +17251,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17054,31 +17357,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17097,20 +17395,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17120,19 +17410,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17163,8 +17451,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17172,18 +17466,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17198,6 +17495,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17257,6 +17561,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17277,6 +17584,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17290,6 +17600,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower2; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17300,32 +17613,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error, data) => { - if (error) { - failWebsocketConnection(this.ws, error.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error, data) => { + if (error) { + const code = error instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17368,6 +17695,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17579,8 +17926,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17624,9 +17969,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17810,7 +18152,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17885,19 +18233,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17976,6 +18311,40 @@ var require_eventsource_stream = __commonJS({ var CR2 = 13; var COLON2 = 58; var SPACE2 = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] < 48 || buffer[i2] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i2 = 0; i2 < length; i2++) { + if (line[i2] !== field[i2]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17995,10 +18364,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18029,63 +18401,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF2) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF2) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18094,17 +18433,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18120,43 +18458,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE2) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18181,12 +18518,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i2 = this.lineChunkIndex; i2 <= this.chunkIndex; i2++) { + const chunk = this.chunks[i2]; + const start = i2 === this.lineChunkIndex ? this.linePos : 0; + const end = i2 === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getArtifactInfo/index.js b/.github/actions/javascript/getArtifactInfo/index.js index 1d7315a4ae65..b511ee174521 100644 --- a/.github/actions/javascript/getArtifactInfo/index.js +++ b/.github/actions/javascript/getArtifactInfo/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index 86ebf2b747d2..c6a083ec32cf 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getPreviousVersion/index.js b/.github/actions/javascript/getPreviousVersion/index.js index 0343158e5a02..dc48a205f79b 100644 --- a/.github/actions/javascript/getPreviousVersion/index.js +++ b/.github/actions/javascript/getPreviousVersion/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js index 78b36a004849..b7443787fc9b 100644 --- a/.github/actions/javascript/getPullRequestDetails/index.js +++ b/.github/actions/javascript/getPullRequestDetails/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js index 21c54003dde2..e868a069ae7e 100644 --- a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js +++ b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error3) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error3 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error3) { + util.errorRequest(client, request2, error3); + util.destroy(socket, error3); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error3) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error3 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error3) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error3 || new RequestAbortedError()); + } + return; + } + error3 = error3 || new RequestAbortedError(); + util.errorRequest(client, request2, error3); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error3); } - util.destroy(body, err); + util.destroy(body, error3); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error3) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error3); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error3) { + stream.off("response", onResponse); + abort(error3); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error3, data) => { - if (error3) { - failWebsocketConnection(this.ws, error3.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error3, data) => { + if (error3) { + const code = error3 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error3.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/isAuthorizedContributor/index.js b/.github/actions/javascript/isAuthorizedContributor/index.js index fea991ad887f..d43a19fced4c 100644 --- a/.github/actions/javascript/isAuthorizedContributor/index.js +++ b/.github/actions/javascript/isAuthorizedContributor/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index ebdf2bf6961c..9321db05e6d7 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -28447,10 +28447,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -28517,7 +28573,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -28529,6 +28589,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -28638,13 +28701,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -32251,6 +32322,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -32297,6 +32369,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -32459,24 +32534,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -32496,6 +32602,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -32539,7 +32649,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -32564,9 +32674,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -32575,6 +32686,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -32700,6 +32815,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -32743,12 +32859,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -32763,7 +32886,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -32771,9 +32897,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -32822,7 +32949,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -32840,6 +32967,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -32852,6 +32996,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -32882,8 +33049,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -32904,11 +33079,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -33264,6 +33446,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -33324,6 +33507,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -33487,16 +33674,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -33511,18 +33704,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -34083,9 +34300,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -34591,8 +34809,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -34763,7 +34981,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -34784,6 +35001,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -34983,7 +35201,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -34993,6 +35210,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -35433,6 +35651,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -35486,6 +35722,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -35495,6 +35732,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -35556,7 +35804,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -35605,9 +35855,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -35615,6 +35877,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -35622,6 +35885,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -35641,6 +35909,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -35668,7 +35937,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -42467,14 +42736,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -42557,7 +42860,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -42687,18 +42994,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -43520,7 +43823,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -43626,31 +43929,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -43669,20 +43967,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -43692,19 +43982,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -43735,8 +44023,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -43744,18 +44038,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -43770,6 +44067,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -43829,6 +44133,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -43849,6 +44156,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -43862,6 +44172,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -43872,32 +44185,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -43940,6 +44267,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -44151,8 +44498,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -44196,9 +44541,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -44382,7 +44724,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -44457,19 +44805,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -44548,6 +44883,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -44567,10 +44936,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -44601,63 +44973,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -44666,17 +45005,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -44692,43 +45030,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -44753,12 +45090,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 62a50ec22616..5eca45f374da 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/postOrReplaceComment/index.js b/.github/actions/javascript/postOrReplaceComment/index.js index fb9eb8df9e7b..195707dedc9f 100644 --- a/.github/actions/javascript/postOrReplaceComment/index.js +++ b/.github/actions/javascript/postOrReplaceComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 126b34462df4..beb367dd3ebd 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str2 = `${val[i]}`; + if (!isValidHeaderValue(str2)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str2); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message2 = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message2 = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message2, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message2 = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message2 = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message2, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/reopenIssueWithComment/index.js b/.github/actions/javascript/reopenIssueWithComment/index.js index f237f20694fd..2fe118ee3e64 100644 --- a/.github/actions/javascript/reopenIssueWithComment/index.js +++ b/.github/actions/javascript/reopenIssueWithComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/reviewerChecklist/index.js b/.github/actions/javascript/reviewerChecklist/index.js index a9419acf5a2e..8e220a2cf59e 100644 --- a/.github/actions/javascript/reviewerChecklist/index.js +++ b/.github/actions/javascript/reviewerChecklist/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/validateReassureOutput/index.js b/.github/actions/javascript/validateReassureOutput/index.js index b26522496b9c..e511f56ffd08 100644 --- a/.github/actions/javascript/validateReassureOutput/index.js +++ b/.github/actions/javascript/validateReassureOutput/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request.aborted || request.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request.aborted) { return; } - util.errorRequest(client, request, err || new RequestAbortedError()); + if (request.completed) { + if (request.upgrade || request.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } + return; + } + util.errorRequest(client, request, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request.aborted || request.completed) { + const abort = (error2) => { + if (request.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request, err); + if (request.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request.onUpgradeError(error2); + }; + const onReady = () => { + try { request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/verifySignedCommits/index.js b/.github/actions/javascript/verifySignedCommits/index.js index 087b5df387fd..d982728230d3 100644 --- a/.github/actions/javascript/verifySignedCommits/index.js +++ b/.github/actions/javascript/verifySignedCommits/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js b/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js index 1e9b71a5c3e0..d5508591a07b 100644 --- a/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js +++ b/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 9aafb02e46eb..46781a17394d 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -88,7 +88,6 @@ git fetch origin tag 1.0.1-0 --no-tags --shallow-exclude=1.0.0-0 # This will fet ## Secrets The GitHub workflows require a large list of secrets to deploy, notify and test the code: 1. `SLACK_WEBHOOK` - Sends Slack notifications via Slack WebHook https://expensify.slack.com/services/B01AX48D7MM -1. `SLACK_RETEST_USER_TOKEN` - Read-only Slack user token with `channels:history` access to `#deployer` and `#retests`, used to link automated retest requests to their Cherry Pick Request threads 1. `OS_BOTIFY_TOKEN` - Personal access token for @OSBotify user in GitHub 1. `CLA_BOTIFY_TOKEN` - Personal access token for @CLABotify user in GitHub 1. `CSC_LINK` - Required to be set for desktop code signing: https://www.electron.build/code-signing.html#travis-appveyor-and-other-ci-servers diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 348d7ddb6fee..0621b4cda065 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -988,9 +988,7 @@ jobs: run: npx bun scripts/createRetestRequestForCP.ts --deploy-sha=${{ needs.prep.outputs.DEPLOY_SHA }} --deploy-tag=${{ needs.prep.outputs.TAG }} env: GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - SLACK_RETEST_USER_TOKEN: ${{ secrets.SLACK_RETEST_USER_TOKEN }} SLACK_RETEST_WEBHOOK: ${{ steps.loadWebhook.outputs.SLACK_RETEST_WEBHOOK }} - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} createRelease: runs-on: blacksmith-2vcpu-ubuntu-2404 From 85f3c93710c148cd1c1dbe544e9740ba24a23035 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:38:27 +0100 Subject: [PATCH 325/781] revert unintended changes --- .github/workflows/README.md | 1 + .github/workflows/deploy.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 46781a17394d..9aafb02e46eb 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -88,6 +88,7 @@ git fetch origin tag 1.0.1-0 --no-tags --shallow-exclude=1.0.0-0 # This will fet ## Secrets The GitHub workflows require a large list of secrets to deploy, notify and test the code: 1. `SLACK_WEBHOOK` - Sends Slack notifications via Slack WebHook https://expensify.slack.com/services/B01AX48D7MM +1. `SLACK_RETEST_USER_TOKEN` - Read-only Slack user token with `channels:history` access to `#deployer` and `#retests`, used to link automated retest requests to their Cherry Pick Request threads 1. `OS_BOTIFY_TOKEN` - Personal access token for @OSBotify user in GitHub 1. `CLA_BOTIFY_TOKEN` - Personal access token for @CLABotify user in GitHub 1. `CSC_LINK` - Required to be set for desktop code signing: https://www.electron.build/code-signing.html#travis-appveyor-and-other-ci-servers diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0621b4cda065..348d7ddb6fee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -988,7 +988,9 @@ jobs: run: npx bun scripts/createRetestRequestForCP.ts --deploy-sha=${{ needs.prep.outputs.DEPLOY_SHA }} --deploy-tag=${{ needs.prep.outputs.TAG }} env: GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + SLACK_RETEST_USER_TOKEN: ${{ secrets.SLACK_RETEST_USER_TOKEN }} SLACK_RETEST_WEBHOOK: ${{ steps.loadWebhook.outputs.SLACK_RETEST_WEBHOOK }} + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} createRelease: runs-on: blacksmith-2vcpu-ubuntu-2404 From eee96ea18dd4fde20db0dbbb583aac075b3e63d2 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 7 Oct 2026 20:04:33 +0200 Subject: [PATCH 326/781] allow company address as default --- src/CONST/index.ts | 1 + src/languages/de.ts | 2 ++ src/languages/el.ts | 2 ++ src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 2 ++ src/languages/it.ts | 2 ++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 2 ++ src/languages/pl.ts | 2 ++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 2 ++ src/libs/ReportActionsUtils.ts | 3 ++- src/libs/actions/Policy/DistanceRate.ts | 5 ++-- src/pages/workspace/WorkspaceOverviewPage.tsx | 27 +++++++++++++++---- tests/actions/PolicyOfficeLocationTest.ts | 22 +++++++++++++++ tests/unit/ReportActionsUtilsTest.ts | 5 ++++ 17 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 73346bd0f6c6..d19304b7a14a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9866,6 +9866,7 @@ const CONST = { CURRENCY: 'WorkspaceOverview-Currency', ADDRESS: 'WorkspaceOverview-Address', OFFICE_LOCATION: 'WorkspaceOverview-OfficeLocation', + COMPANY_ADDRESS_OFFICE_LOCATION: 'WorkspaceOverview-CompanyAddressOfficeLocation', ADD_OFFICE_LOCATION: 'WorkspaceOverview-AddOfficeLocation', DELETE_OFFICE_LOCATION: 'WorkspaceOverview-DeleteOfficeLocation', PLAN_TYPE: 'WorkspaceOverview-PlanType', diff --git a/src/languages/de.ts b/src/languages/de.ts index cc0034f4c4e9..b6f331e22868 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7719,6 +7719,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, deleteOfficeLocation: 'Bürostandort löschen', deleteOfficeLocationConfirmation: 'Möchtest du diesen Bürostandort wirklich löschen?', existingOfficeLocationError: 'Ein Bürostandort mit diesem Namen existiert bereits', + setCompanyAddressAsPrimaryConfirmation: 'Die Firmenadresse als Hauptstandort festlegen?', }, bankAccount: { continueWithSetup: 'Einrichtung fortsetzen', @@ -9526,6 +9527,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `hat die Adresse des Bürostandorts „${name}“ in „${newAddress}“ geändert (zuvor „${oldAddress}“)`, setAsPrimary: ({name}: {name: string}) => `hat den Bürostandort „${name}“ als Hauptstandort festgelegt`, + setCompanyAddressAsPrimary: 'hat die Firmenadresse als Hauptstandort festgelegt', removed: ({name}: {name: string}) => `hat den Bürostandort „${name}“ entfernt`, }, commuterExclusions: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 14523cbb619e..9614c24bf569 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8012,6 +8012,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ deleteOfficeLocation: 'Διαγραφή τοποθεσίας γραφείου', deleteOfficeLocationConfirmation: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή την τοποθεσία γραφείου;', existingOfficeLocationError: 'Υπάρχει ήδη τοποθεσία γραφείου με αυτό το όνομα', + setCompanyAddressAsPrimaryConfirmation: 'Ορισμός της διεύθυνσης εταιρείας ως κύριου γραφείου;', }, bankAccount: { continueWithSetup: 'Συνεχίστε τη ρύθμιση', @@ -9651,6 +9652,7 @@ ${reportName}`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `άλλαξε τη διεύθυνση της τοποθεσίας γραφείου «${name}» σε «${newAddress}» (προηγουμένως «${oldAddress}»)`, setAsPrimary: ({name}: {name: string}) => `όρισε την τοποθεσία γραφείου «${name}» ως κύρια`, + setCompanyAddressAsPrimary: 'όρισε τη διεύθυνση εταιρείας ως κύρια', removed: ({name}: {name: string}) => `αφαίρεσε την τοποθεσία γραφείου «${name}»`, }, commuterExclusions: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 05b074bd6dcc..3f50fdff9045 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8230,6 +8230,7 @@ const translations = { deleteOfficeLocation: 'Delete office location', deleteOfficeLocationConfirmation: 'Are you sure you want to delete this office location?', existingOfficeLocationError: 'An office location with this name already exists', + setCompanyAddressAsPrimaryConfirmation: 'Set the company address as primary office?', }, bankAccount: { continueWithSetup: 'Continue setup', @@ -9677,6 +9678,7 @@ const translations = { changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `changed the address of the office location "${name}" to "${newAddress}" (previously "${oldAddress}")`, setAsPrimary: ({name}: {name: string}) => `set the office location "${name}" as primary`, + setCompanyAddressAsPrimary: 'set the company address as primary', removed: ({name}: {name: string}) => `removed the office location "${name}"`, }, commuterExclusions: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 663374bcd2e5..037805a73a55 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7738,6 +7738,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, deleteOfficeLocation: 'Eliminar ubicación de oficina', deleteOfficeLocationConfirmation: '¿Estás seguro de que quieres eliminar esta ubicación de oficina?', existingOfficeLocationError: 'Ya existe una ubicación de oficina con este nombre', + setCompanyAddressAsPrimaryConfirmation: '¿Establecer la dirección física de la empresa como oficina principal?', }, bankAccount: { continueWithSetup: 'Continuar con la configuración', @@ -9302,6 +9303,7 @@ ${reportName}`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `cambió la dirección de la ubicación de oficina "${name}" a "${newAddress}" (anteriormente "${oldAddress}")`, setAsPrimary: ({name}: {name: string}) => `estableció la ubicación de oficina "${name}" como principal`, + setCompanyAddressAsPrimary: 'estableció la dirección física de la empresa como principal', removed: ({name}: {name: string}) => `eliminó la ubicación de oficina "${name}"`, }, commuterExclusions: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b8bc3c41a150..e28e34890077 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7738,6 +7738,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, deleteOfficeLocation: 'Supprimer le lieu de travail', deleteOfficeLocationConfirmation: 'Voulez-vous vraiment supprimer ce lieu de travail ?', existingOfficeLocationError: 'Un lieu de travail portant ce nom existe déjà', + setCompanyAddressAsPrimaryConfirmation: 'Définir l’adresse de l’entreprise comme bureau principal ?', }, bankAccount: { continueWithSetup: 'Continuer la configuration', @@ -9546,6 +9547,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `a modifié l’adresse du lieu de travail « ${name} » en « ${newAddress} » (auparavant « ${oldAddress} »)`, setAsPrimary: ({name}: {name: string}) => `a défini le lieu de travail « ${name} » comme principal`, + setCompanyAddressAsPrimary: 'a défini l’adresse de l’entreprise comme principale', removed: ({name}: {name: string}) => `a supprimé le lieu de travail « ${name} »`, }, commuterExclusions: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 5019ca4693c1..aa2cfb461ec9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7675,6 +7675,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, deleteOfficeLocation: 'Elimina sede dell’ufficio', deleteOfficeLocationConfirmation: 'Sei sicuro di voler eliminare questa sede dell’ufficio?', existingOfficeLocationError: 'Esiste già una sede dell’ufficio con questo nome', + setCompanyAddressAsPrimaryConfirmation: 'Impostare l’indirizzo dell’azienda come sede principale?', }, bankAccount: { continueWithSetup: 'Continua configurazione', @@ -9474,6 +9475,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `ha modificato l’indirizzo della sede dell’ufficio "${name}" in "${newAddress}" (precedentemente "${oldAddress}")`, setAsPrimary: ({name}: {name: string}) => `ha impostato la sede dell’ufficio "${name}" come principale`, + setCompanyAddressAsPrimary: 'ha impostato l’indirizzo dell’azienda come principale', removed: ({name}: {name: string}) => `ha rimosso la sede dell’ufficio "${name}"`, }, commuterExclusions: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9879c39c2c15..df0d7b594649 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7582,6 +7582,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から deleteOfficeLocation: 'オフィスの所在地を削除', deleteOfficeLocationConfirmation: 'このオフィスの所在地を削除してもよろしいですか?', existingOfficeLocationError: 'この名前のオフィスの所在地はすでに存在します', + setCompanyAddressAsPrimaryConfirmation: '会社住所をメインのオフィスに設定しますか?', }, bankAccount: { continueWithSetup: 'セットアップを続行', @@ -9342,6 +9343,7 @@ ${reportName}`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `オフィスの所在地「${name}」の住所を「${newAddress}」(以前は「${oldAddress}」)に変更しました`, setAsPrimary: ({name}: {name: string}) => `オフィスの所在地「${name}」をメインに設定しました`, + setCompanyAddressAsPrimary: '会社住所をメインに設定しました', removed: ({name}: {name: string}) => `オフィスの所在地「${name}」を削除しました`, }, commuterExclusions: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index aab914274408..62247756f3ee 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7656,6 +7656,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, deleteOfficeLocation: 'Kantoorlocatie verwijderen', deleteOfficeLocationConfirmation: 'Weet je zeker dat je deze kantoorlocatie wilt verwijderen?', existingOfficeLocationError: 'Er bestaat al een kantoorlocatie met deze naam', + setCompanyAddressAsPrimaryConfirmation: 'Het adres van het bedrijf instellen als primair kantoor?', }, bankAccount: { continueWithSetup: 'Setup voortzetten', @@ -9446,6 +9447,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `heeft het adres van de kantoorlocatie ‘${name}’ gewijzigd in ‘${newAddress}’ (voorheen ‘${oldAddress}’)`, setAsPrimary: ({name}: {name: string}) => `heeft de kantoorlocatie ‘${name}’ als primair ingesteld`, + setCompanyAddressAsPrimary: 'heeft het adres van het bedrijf als primair ingesteld', removed: ({name}: {name: string}) => `heeft de kantoorlocatie ‘${name}’ verwijderd`, }, commuterExclusions: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a69697b7352d..439dffeef024 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7678,6 +7678,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, deleteOfficeLocation: 'Usuń lokalizację biura', deleteOfficeLocationConfirmation: 'Czy na pewno chcesz usunąć tę lokalizację biura?', existingOfficeLocationError: 'Lokalizacja biura o tej nazwie już istnieje', + setCompanyAddressAsPrimaryConfirmation: 'Ustawić adres firmy jako główne biuro?', }, bankAccount: { continueWithSetup: 'Kontynuuj konfigurację', @@ -9456,6 +9457,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `zmieniono adres lokalizacji biura „${name}” na „${newAddress}” (poprzednio „${oldAddress}”)`, setAsPrimary: ({name}: {name: string}) => `ustawiono lokalizację biura „${name}” jako główną`, + setCompanyAddressAsPrimary: 'ustawiono adres firmy jako główny', removed: ({name}: {name: string}) => `usunięto lokalizację biura „${name}”`, }, commuterExclusions: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c0325c9cc111..4cf779ddeaed 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7661,6 +7661,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, deleteOfficeLocation: 'Excluir local de escritório', deleteOfficeLocationConfirmation: 'Tem certeza de que deseja excluir este local de escritório?', existingOfficeLocationError: 'Já existe um local de escritório com este nome', + setCompanyAddressAsPrimaryConfirmation: 'Definir o endereço da empresa como escritório principal?', }, bankAccount: { continueWithSetup: 'Continuar configuração', @@ -9444,6 +9445,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `alterou o endereço do local de escritório "${name}" para "${newAddress}" (antes "${oldAddress}")`, setAsPrimary: ({name}: {name: string}) => `definiu o local de escritório "${name}" como principal`, + setCompanyAddressAsPrimary: 'definiu o endereço da empresa como principal', removed: ({name}: {name: string}) => `removeu o local de escritório "${name}"`, }, commuterExclusions: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 451904e9acba..ff56d890a398 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7397,6 +7397,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM deleteOfficeLocation: '删除办公地点', deleteOfficeLocationConfirmation: '确定要删除此办公地点吗?', existingOfficeLocationError: '已存在同名的办公地点', + setCompanyAddressAsPrimaryConfirmation: '将公司地址设为主要办公地点?', }, bankAccount: { continueWithSetup: '继续设置', @@ -9098,6 +9099,7 @@ ${reportName}`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `已将办公地点“${name}”的地址更改为“${newAddress}”(原为“${oldAddress}”)`, setAsPrimary: ({name}: {name: string}) => `已将办公地点“${name}”设为主要地点`, + setCompanyAddressAsPrimary: '已将公司地址设为主要地点', removed: ({name}: {name: string}) => `已删除办公地点“${name}”`, }, commuterExclusions: { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 8863ed0b99f4..49b5684db489 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4715,7 +4715,8 @@ function getUpdatedOfficeLocationMessage(translate: LocalizedTranslate, reportAc } if (updatedField === CONST.POLICY.OFFICE_LOCATION_FIELD.IS_DEFAULT) { - return translate('workspaceActions.officeLocation.setAsPrimary', {name}); + // Unsetting the primary office makes the company address the primary one + return newValue === false ? translate('workspaceActions.officeLocation.setCompanyAddressAsPrimary') : translate('workspaceActions.officeLocation.setAsPrimary', {name}); } return getReportActionText(reportAction); diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index c27d7d69daa1..18bcdcc3570d 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -840,8 +840,9 @@ function addOfficeLocation(policyID: string, officeLocations: Record officeLocation.isDefault && officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ); + const defaultOfficeID = officeLocationEntries.find(([, officeLocation]) => officeLocation.isDefault && officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)?.[0]; + const isCompanyAddressPrimary = !defaultOfficeID; + + // Taking the primary flag off the primary office makes the company address the primary one + const setCompanyAddressAsPrimary = () => { + showConfirmModal({ + title: translate('common.companyAddress'), + prompt: translate('workspace.officeLocations.setCompanyAddressAsPrimaryConfirmation'), + confirmText: translate('workspace.officeLocations.setAsPrimary'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM || !defaultOfficeID) { + return; + } + updateOfficeLocation(routePolicyID, policy?.officeLocations, defaultOfficeID, {isDefault: false}); + }); + }; const shouldShowOfficeLocationsSection = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS) && (!readOnly || shouldShowCompanyAddressOffice || officeLocationEntries.length > 0); const shouldShowRulesDocumentSubSection = isPolicyAdmin || hasRulesDocument; @@ -742,7 +756,10 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa > {shouldShowCompanyAddressOffice && ( - + {translate('common.companyAddress')} diff --git a/tests/actions/PolicyOfficeLocationTest.ts b/tests/actions/PolicyOfficeLocationTest.ts index 2f2c0ad79396..4ed3b94cd0b3 100644 --- a/tests/actions/PolicyOfficeLocationTest.ts +++ b/tests/actions/PolicyOfficeLocationTest.ts @@ -227,6 +227,28 @@ describe('actions/PolicyOfficeLocation', () => { expect(officeLocations.OFFICE2?.name).toBe('New Jersey Office'); expect(officeLocations.OFFICE2?.errors).toBeFalsy(); }); + + it('takes the primary flag off the primary office to make the company address primary, and restores it when that fails', async () => { + // When the primary office stops being primary while the request is in flight, as making the company address + // primary does + mockFetch?.pause?.(); + updateOfficeLocation(policyID, {OFFICE1: HEADQUARTERS}, 'OFFICE1', {isDefault: false}); + await waitForBatchedUpdates(); + + // Then no office shows as primary, and the request clears the flag + expect((await getPolicy(policyID))?.officeLocations?.OFFICE1?.isDefault).toBe(false); + expect(getRequestParams(WRITE_COMMANDS.UPDATE_OFFICE_LOCATION).isDefault).toBe('false'); + + // When the request fails + mockFetch?.fail?.(); + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then the office is the primary one again and shows an error + const officeLocation = (await getPolicy(policyID))?.officeLocations?.OFFICE1; + expect(officeLocation?.isDefault).toBe(true); + expect(Object.keys(officeLocation?.errors ?? {}).length).toBeGreaterThan(0); + }); }); describe('deleteOfficeLocation', () => { diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 30473e16c00e..2e48f5702db9 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -4644,6 +4644,11 @@ describe('ReportActionsUtils', () => { 'changed the address of the office location "HQ" to "900 Asbury Ave, Suite 2, Ocean City, NJ 08226" (previously "88 Kearny St, San Francisco, CA 94108")', ], ['a new primary office', {updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.IS_DEFAULT, name: 'HQ', newValue: true, oldValue: false}, 'set the office location "HQ" as primary'], + [ + 'the company address becoming primary', + {updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.IS_DEFAULT, name: 'HQ', newValue: false, oldValue: true}, + 'set the company address as primary', + ], ])('describes %s', (_description, originalMessage, expected) => { // Given an office update that changed a single field const action: ReportAction = { From 25ce76936b75d44c569f66c06f94dc532a763382 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 7 Oct 2026 20:27:23 +0200 Subject: [PATCH 327/781] better UI --- src/pages/workspace/WorkspaceOverviewPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 90c55ebe8f72..2a1440e88049 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -748,9 +748,9 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa
From 006d89242fa8514129280086888848b6ca886bad Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Wed, 7 Oct 2026 23:14:02 +0000 Subject: [PATCH 328/781] Address review comments on the vacation delegate form - Require the clear after time to be at least one minute ahead, matching the error copy - Show the delegator list instead of the form while the user is someone else's delegate - Pass the domain vacation delegate selector inline, as React Compiler memoizes it - Split the help doc step into action-led steps for the date and time Co-authored-by: Kevin Brian Bader --- .../settings/Delegate-when-out-of-office.md | 5 +-- src/components/VacationDelegateForm.tsx | 3 +- src/libs/VacationDelegateUtils.ts | 12 ++++++- .../DomainMemberVacationDelegateFormPage.tsx | 5 ++- .../CustomStatus/VacationDelegateFormPage.tsx | 35 +++++++++++++------ tests/ui/VacationDelegateFormPageTest.tsx | 16 +++++++++ tests/unit/libs/VacationDelegateUtilsTest.ts | 12 +++++++ 7 files changed, 71 insertions(+), 17 deletions(-) diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md index 5eab976fc0f5..8d3097664a55 100644 --- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md +++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md @@ -19,8 +19,9 @@ All delegate actions are tracked in the report history for full visibility. 1. Go to **Account > Profile > Vacation delegate**. 2. Select the member who should approve on your behalf. -3. Optional (recommended): Under **Clear after**, pick the last day the delegate should cover. Then, under **Time**, pick when the delegate should clear on that day. If you don't change the time, the delegate clears at the end of the day. -4. Click **Save**. +3. Under **Clear after (recommended)**, pick the last day the delegate should cover (optional). +4. Under **Time**, pick when the delegate clears on that day (defaults to the end of the day). +5. Click **Save**. Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. The delegate and the date and time it clears are shown under **Vacation delegate** on your profile. diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx index 887f9c9361d9..2ac867b6e5bc 100644 --- a/src/components/VacationDelegateForm.tsx +++ b/src/components/VacationDelegateForm.tsx @@ -11,6 +11,7 @@ import { getVacationDelegateClearDate, getVacationDelegateClearDateTime, getVacationDelegateLocalClearDateTime, + isVacationDelegateClearAfterTooSoon, isVacationDelegateExpired, } from '@libs/VacationDelegateUtils'; import {getDatePassedError} from '@libs/ValidationUtils'; @@ -90,7 +91,7 @@ function VacationDelegateForm({vacationDelegate, description, onChangeDelegate, const dateError = getDatePassedError(translate, values[INPUT_IDS.CLEAR_AFTER_DATE]); if (dateError) { formErrors[INPUT_IDS.CLEAR_AFTER_DATE] = dateError; - } else if (isVacationDelegateExpired(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone?.selected))) { + } else if (isVacationDelegateClearAfterTooSoon(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone?.selected))) { formErrors[INPUT_IDS.CLEAR_AFTER_TIME] = translate('common.error.invalidTimeShouldBeFuture'); } return formErrors; diff --git a/src/libs/VacationDelegateUtils.ts b/src/libs/VacationDelegateUtils.ts index 4cebb3ee6ae1..43dce0a20e1d 100644 --- a/src/libs/VacationDelegateUtils.ts +++ b/src/libs/VacationDelegateUtils.ts @@ -3,7 +3,7 @@ import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {Locale as DateFnsLocale} from 'date-fns'; -import {format, isValid, parse} from 'date-fns'; +import {addMinutes, format, isValid, parse} from 'date-fns'; import {fromZonedTime} from 'date-fns-tz'; import DateUtils from './DateUtils'; @@ -79,6 +79,15 @@ function isVacationDelegateExpired(clearAfter: string | undefined): boolean { return !!clearAfter && clearAfter <= DateUtils.getDBTime(); } +/** + * Whether the picked clearAfter datetime is less than one minute from now. Such a delegate could clear while the request is still in + * flight, so the form asks for a time at least one minute ahead. + */ +function isVacationDelegateClearAfterTooSoon(clearAfter: string | undefined): boolean { + // Both values are UTC in the database format, so they compare as strings. + return !!clearAfter && clearAfter < DateUtils.formatDBTimeWithoutMilliseconds(addMinutes(new Date(), 1).valueOf()); +} + export { getVacationDelegateClearAfter, getVacationDelegateClearDate, @@ -86,4 +95,5 @@ export { getVacationDelegateLocalClearDateTime, formatVacationDelegateClearDateTime, isVacationDelegateExpired, + isVacationDelegateClearAfterTooSoon, }; diff --git a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx index b6d802032c51..fcf58dfd335a 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegateFormPage.tsx @@ -22,7 +22,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {vacationDelegateSelector} from '@selectors/Domain'; -import React, {useMemo} from 'react'; +import React from 'react'; type DomainMemberVacationDelegateFormPageProps = PlatformStackScreenProps; @@ -32,9 +32,8 @@ function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDeleg const {login: currentUserLogin} = useCurrentUserPersonalDetails(); - const memberVacationDelegateSelector = useMemo(() => vacationDelegateSelector(accountID), [accountID]); const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { - selector: memberVacationDelegateSelector, + selector: vacationDelegateSelector(accountID), }); const [personalDetails] = usePersonalDetail(accountID); diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx index 23167ce66e10..e1fb8b2de6d0 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx @@ -1,3 +1,4 @@ +import DelegatorList from '@components/DelegatorList'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import VacationDelegateForm from '@components/VacationDelegateForm'; @@ -6,6 +7,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import {clearDraftValues} from '@libs/actions/FormActions'; import {clearVacationDelegateError, deleteVacationDelegate, setVacationDelegate} from '@libs/actions/VacationDelegate'; @@ -19,6 +21,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useNavigation} from '@react-navigation/native'; import React, {useRef} from 'react'; +import {View} from 'react-native'; function goBackToProfile() { clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); @@ -33,11 +36,13 @@ function goBack() { function VacationDelegateFormPage() { const {translate} = useLocalize(); + const styles = useThemeStyles(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const {showConfirmModal} = useConfirmModal(); const navigation = useNavigation(); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + const hasActiveDelegations = !!vacationDelegate?.delegatorFor?.length; const isSubmittingRef = useRef(false); @@ -112,16 +117,26 @@ function VacationDelegateFormPage() { title={translate('common.vacationDelegate')} onBackButtonPress={goBack} /> - Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_SELECT)} - onSubmit={onSubmit} - onRemove={onRemove} - errors={getVacationDelegateErrors(vacationDelegate)} - pendingAction={vacationDelegate?.pendingAction} - onCloseError={() => clearVacationDelegateError(vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} - /> + {/* While the user is someone else's delegate, they can't change their own, same as on the member picker */} + {hasActiveDelegations ? ( + + + + ) : ( + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_SELECT)} + onSubmit={onSubmit} + onRemove={onRemove} + errors={getVacationDelegateErrors(vacationDelegate)} + pendingAction={vacationDelegate?.pendingAction} + onCloseError={() => clearVacationDelegateError(vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} + /> + )} ); } diff --git a/tests/ui/VacationDelegateFormPageTest.tsx b/tests/ui/VacationDelegateFormPageTest.tsx index 2e86d9859789..17b5a402ab6a 100644 --- a/tests/ui/VacationDelegateFormPageTest.tsx +++ b/tests/ui/VacationDelegateFormPageTest.tsx @@ -223,6 +223,22 @@ describe('VacationDelegateFormPage', () => { expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route); }); + it('shows the delegator list instead of the form while the user is someone else’s delegate', async () => { + // Given a saved delegate, while the user is also the vacation delegate for another member + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: CREATOR_EMAIL, delegate: ORIGINAL_DELEGATE_EMAIL, delegatorFor: [DELEGATE_A_EMAIL]}); + }); + + // When the form page opens + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the form is replaced by the explanation, so the user can't change or remove their own delegate, same as on the member picker + expect(screen.getByText(TestHelper.translateLocal('statusPage.cannotSetVacationDelegate'))).toBeOnTheScreen(); + expect(screen.queryByTestId('select-delegate-a')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('remove-delegate')).not.toBeOnTheScreen(); + }); + it('pops only the form and drops the unsaved pick when the header back button is pressed', async () => { // Given a member picked on the picker but not saved yet jest.mocked(Navigation.goBack).mockClear(); diff --git a/tests/unit/libs/VacationDelegateUtilsTest.ts b/tests/unit/libs/VacationDelegateUtilsTest.ts index b520047b82f3..da5de9ff102d 100644 --- a/tests/unit/libs/VacationDelegateUtilsTest.ts +++ b/tests/unit/libs/VacationDelegateUtilsTest.ts @@ -4,6 +4,7 @@ import { getVacationDelegateClearDate, getVacationDelegateClearDateTime, getVacationDelegateLocalClearDateTime, + isVacationDelegateClearAfterTooSoon, isVacationDelegateExpired, } from '@libs/VacationDelegateUtils'; @@ -96,4 +97,15 @@ describe('VacationDelegateUtils', () => { expect(isVacationDelegateExpired(undefined)).toBe(false); }); }); + + describe('isVacationDelegateClearAfterTooSoon', () => { + it('is true when the clear after datetime is less than one minute from now', () => { + // Given the current time is 2026-09-28 12:00 UTC + // When a time 30 seconds ahead, exactly one minute ahead, and no time are checked + // Then only the one under a minute ahead is too soon, since it could clear while the save request is in flight + expect(isVacationDelegateClearAfterTooSoon('2026-09-28 12:00:30')).toBe(true); + expect(isVacationDelegateClearAfterTooSoon('2026-09-28 12:01:00')).toBe(false); + expect(isVacationDelegateClearAfterTooSoon(undefined)).toBe(false); + }); + }); }); From 58f320b9920b51c638929bb48c3606b0f69e7aaf Mon Sep 17 00:00:00 2001 From: Phuoc Hiep Date: Thu, 8 Oct 2026 10:32:05 +0700 Subject: [PATCH 329/781] Render SearchColumnsPage inside OnyxListItemProvider in its test After the main merge, SearchColumnsPage reads the session through usePolicyForMovingExpenses, which needs OnyxListItemProvider. Wrap the render in the provider and return the form only for its own Onyx key. --- tests/ui/SearchColumnsPageTest.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/ui/SearchColumnsPageTest.tsx b/tests/ui/SearchColumnsPageTest.tsx index d64d732eb229..5c8b96f9eaea 100644 --- a/tests/ui/SearchColumnsPageTest.tsx +++ b/tests/ui/SearchColumnsPageTest.tsx @@ -1,6 +1,7 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import type ColumnsSettingsList from '@components/ColumnsSettingsList'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; import MockText from '@components/Text'; import useOnyx from '@hooks/useOnyx'; @@ -9,6 +10,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {buildFilterFormValuesFromQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; import SearchColumnsPage from '@src/pages/Search/SearchColumnsPage'; import SCREENS from '@src/SCREENS'; @@ -36,12 +38,16 @@ describe('SearchColumnsPage', () => { } const form = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); - (jest.mocked(useOnyx) as jest.Mock).mockReturnValue([form]); + (jest.mocked(useOnyx) as jest.Mock).mockImplementation((key: string) => [key === ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM ? form : undefined]); mockGetRootState.mockReturnValue({ routes: [{name: NAVIGATORS.TAB_NAVIGATOR, params: {screen: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, params: {screen: SCREENS.SEARCH.ROOT, params: {q: input}}}}], }); - render(); + render( + + + , + ); fireEvent.press(screen.getByText('Save columns')); const [route] = jest.mocked(Navigation.navigate).mock.calls.at(-1) ?? []; From bb885aab25c0f9eca6d448aa91d8a2c4d91fa077 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:56:28 +0700 Subject: [PATCH 330/781] fix: hide redundant MARKED_REIMBURSED row in Search when sibling PAY is only in the snapshot --- src/libs/ReportActionsUtils.ts | 57 +++++++---- src/libs/SearchUIUtils.ts | 5 +- tests/unit/ReportActionsUtilsTest.ts | 133 +++++++++++++++++++++++++ tests/unit/Search/SearchUIUtilsTest.ts | 47 +++++++++ 4 files changed, 220 insertions(+), 22 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index eeffab614908..1804440581ca 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1304,6 +1304,7 @@ function shouldReportActionBeVisible( canUserPerformWriteAction?: boolean, currentUserAccountID?: number, reportID?: string, + reportActions?: OnyxEntry, ): boolean { if (!reportAction) { return false; @@ -1380,7 +1381,7 @@ function shouldReportActionBeVisible( // The isNewDot/shouldShow are baked at write time and can be stale for actions created outside NewDot (e.g OldDot or a background job). // Not applied to REIMBURSED, which carries bank account details PAY doesn't replace. - if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED) && hasSiblingPayReportAction(reportAction, reportAction.reportID ?? reportID)) { + if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED) && hasSiblingPayReportAction(reportAction, reportAction.reportID ?? reportID, reportActions)) { return false; } } @@ -1414,6 +1415,7 @@ function isReportActionVisible( visibleReportActions?: VisibleReportActionsDerivedValue, // TODO: Remove optional (?) once all callers pass currentUserAccountID. Refactor issue: https://github.com/Expensify/App/issues/66408 currentUserAccountID?: number, + reportActions?: OnyxEntry, ): boolean { if (!reportAction?.reportActionID) { return false; @@ -1423,18 +1425,18 @@ function isReportActionVisible( // from what's cached in visibleReportActions (which reflects persisted Onyx data). // We must recalculate visibility at runtime to ensure accuracy for these transient states. if (reportAction.pendingAction) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, reportActions); } if (visibleReportActions && reportID) { const reportCache = visibleReportActions[reportID]; if (!reportCache) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, reportActions); } const staticVisibility = reportCache[reportAction.reportActionID]; // If action is not in derived value cache, fall back to runtime calculation if (staticVisibility === undefined) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, reportActions); } if (!staticVisibility) { return false; @@ -1444,7 +1446,7 @@ function isReportActionVisible( } return true; } - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID, reportActions); } /** @@ -1935,24 +1937,13 @@ function isPaymentAttemptBoundary(action: OnyxEntry): boolean { } /** - * Finds MARKED_REIMBURSED actions with a PAY sibling in the same payment attempt. A historical PAY + * Finds the MARKED_REIMBURSED actions that have a PAY sibling in the same payment attempt. A historical PAY * must not hide a later manual reimbursement after that payment was canceled or failed. - * The write-time isNewDot/shouldShow flags can be stale for background jobs (Expensify/Expensify#636674). */ -function hasSiblingPayReportAction(reportAction: OnyxEntry, reportID: string | undefined): boolean { - if (!reportID || !reportAction?.reportActionID) { - return false; - } - - const reportActions = getAllReportActions(reportID); - // An action absent from loaded history cannot be matched. Avoid caching the empty fallback for unknown reports. - if (!reportActions[reportAction.reportActionID]) { - return false; - } - +function getPaySiblingActionIDs(reportActions: ReportActions): Set { const cachedSiblings = paySiblingCache.get(reportActions); if (cachedSiblings) { - return cachedSiblings.has(reportAction.reportActionID); + return cachedSiblings; } const paymentActions = getSortedReportActions( @@ -1982,7 +1973,33 @@ function hasSiblingPayReportAction(reportAction: OnyxEntry, report // Cache all siblings together, but invalidate on any history update: even a positive match can // change if an intervening cancellation arrives later. Weak keys release obsolete snapshots. paySiblingCache.set(reportActions, siblings); - return siblings.has(reportAction.reportActionID); + return siblings; +} + +/** + * Checks whether a MARKED_REIMBURSED action has a PAY sibling in the same payment attempt. + * The write-time isNewDot/shouldShow flags can be stale for background jobs (Expensify/Expensify#636674). + * `callerReportActions` (e.g. a Search snapshot) is used when the loaded Onyx history can't answer, e.g. the report's timeline isn't loaded. + */ +function hasSiblingPayReportAction(reportAction: OnyxEntry, reportID: string | undefined, callerReportActions?: OnyxEntry): boolean { + if (!reportID || !reportAction?.reportActionID) { + return false; + } + + const reportActionID = reportAction.reportActionID; + const loadedReportActions = getAllReportActions(reportID); + // An action absent from a collection cannot be matched. Avoid caching the empty fallback for unknown reports. + if (loadedReportActions[reportActionID]) { + if (getPaySiblingActionIDs(loadedReportActions).has(reportActionID)) { + return true; + } + // A loaded history that contains a PAY also holds the cancellations that separate payment attempts, so its answer is final. + if (Object.values(loadedReportActions).some((action) => isPayAction(action))) { + return false; + } + } + + return !!callerReportActions?.[reportActionID] && getPaySiblingActionIDs(callerReportActions).has(reportActionID); } function isTaskAction(reportAction: OnyxEntry): boolean { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 59034706b53b..148647b89e62 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2560,7 +2560,8 @@ function getReportActionsSections( for (const key in data) { if (isReportActionEntry(key)) { const reportIDFromKey = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); - const reportActions = Object.values(data[key]); + const reportActionsCollection = data[key]; + const reportActions = Object.values(reportActionsCollection); n += reportActions.length; for (const reportAction of reportActions) { // Always use the container reportID so "In " rows open the parent chat, not a child task/thread report. @@ -2577,7 +2578,7 @@ function getReportActionsSections( const isReportArchived = isArchivedReport(data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]); if ( !reportID || - !isReportActionVisible(reportAction, reportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData) || + !isReportActionVisible(reportAction, reportID, canUserPerformWriteAction(report, isReportArchived), visibleReportActionsData, undefined, reportActionsCollection) || isDeletedAction(reportAction) || isResolvedActionableWhisper(reportAction) || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED || diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 6875b41c6ee7..bb3e68e8a989 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -2831,6 +2831,139 @@ describe('ReportActionsUtils', () => { expect(actual).toBe(true); }); + it('should return false for MARKED_REIMBURSED with a sibling IOU PAY action in the passed collection when the report timeline is not loaded into Onyx', () => { + // Given a MARKED_REIMBURSED action with stale write-time flags on a report whose actions are not in the Onyx collection + const reportID = 'reportOnlyInSearchSnapshot'; + const markedReimbursedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: '1', + reportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + }; + // And a caller-provided collection (e.g. a Search snapshot) that contains a sibling IOU PAY action + const payAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportActionID: '2', + reportID, + created: '2025-01-01 00:00:01', + message: [{type: 'TEXT', style: 'normal', text: 'paid'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, + } as ReportAction; + const snapshotReportActions = { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + [payAction.reportActionID]: payAction, + }; + + // When visibility is checked with the caller's collection + const actual = ReportActionsUtils.isReportActionVisible(markedReimbursedAction, reportID, true, undefined, undefined, snapshotReportActions); + + // Then the MARKED_REIMBURSED action should NOT be visible, because the sibling is found in the passed collection + expect(actual).toBe(false); + }); + + it('should return false for MARKED_REIMBURSED when Onyx only has the action and the passed collection has the sibling IOU PAY action', async () => { + // Given a partially loaded Onyx history that contains the MARKED_REIMBURSED action but no PAY action + const reportID = 'reportPartiallyLoadedInOnyx'; + const markedReimbursedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: '1', + reportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + }); + // And a caller-provided collection (e.g. a Search snapshot) that contains the sibling IOU PAY action + const payAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportActionID: '2', + reportID, + created: '2025-01-01 00:00:01', + message: [{type: 'TEXT', style: 'normal', text: 'paid'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, + } as ReportAction; + const snapshotReportActions = { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + [payAction.reportActionID]: payAction, + }; + + // When visibility is checked with the caller's collection + const actual = ReportActionsUtils.isReportActionVisible(markedReimbursedAction, reportID, true, undefined, undefined, snapshotReportActions); + + // Then the MARKED_REIMBURSED action should NOT be visible, because Onyx can't answer and the caller's collection holds the sibling + expect(actual).toBe(false); + }); + + it('should return true for MARKED_REIMBURSED when the loaded Onyx history separates the PAY action with a cancellation, even if the passed collection has the PAY action', async () => { + // Given a loaded Onyx history where a PAY was canceled before the manual MARKED_REIMBURSED action + const reportID = 'reportWithCanceledPayInOnyx'; + const payAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportActionID: '1', + reportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'paid'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: reportID, amount: 100, currency: CONST.CURRENCY.USD}, + } as ReportAction; + const canceledAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, + reportActionID: '2', + reportID, + created: '2025-01-01 00:00:10', + message: [{type: 'TEXT', style: 'normal', text: 'canceled payment'}], + originalMessage: {}, + } as ReportAction; + const markedReimbursedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: '3', + reportID, + created: '2025-01-01 00:00:20', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [payAction.reportActionID]: payAction, + [canceledAction.reportActionID]: canceledAction, + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + }); + // And a filtered caller-provided collection that omits the cancellation + const snapshotReportActions = { + [payAction.reportActionID]: payAction, + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + }; + + // When visibility is checked with the caller's collection + const actual = ReportActionsUtils.isReportActionVisible(markedReimbursedAction, reportID, true, undefined, undefined, snapshotReportActions); + + // Then the MARKED_REIMBURSED action should stay visible, because the complete Onyx history shows it belongs to a new payment attempt + expect(actual).toBe(true); + }); + + it('should return true for MARKED_REIMBURSED when the passed collection has no PAY action', () => { + // Given a MARKED_REIMBURSED action on a report whose actions are not in the Onyx collection + const reportID = 'reportOnlyInSearchSnapshotWithoutPay'; + const markedReimbursedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: '1', + reportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + }; + + // When visibility is checked with a caller-provided collection that only contains the MARKED_REIMBURSED action + const actual = ReportActionsUtils.isReportActionVisible(markedReimbursedAction, reportID, true, undefined, undefined, { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + }); + + // Then the MARKED_REIMBURSED action should stay visible, because there is no PAY action to replace it + expect(actual).toBe(true); + }); + it('should return true for TAKE_CONTROL when automaticAction is false', () => { const reportAction = buildTakeControlActionFixture( { diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index f3f2edc49e80..aa1df4b1bde3 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -2782,6 +2782,53 @@ describe('SearchUIUtils', () => { expect(allReportActionsLength).toBe(6); }); + it('should hide a MARKED_REIMBURSED action that has a sibling PAY action in the Search snapshot when the report is not loaded into Onyx', () => { + // Given a CHAT search result whose report only exists in the snapshot, with a stale MARKED_REIMBURSED action and its sibling PAY action + const snapshotReportID = 'snapshotOnlyReportID'; + const markedReimbursedAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + reportActionID: 'markedReimbursedActionID', + reportID: snapshotReportID, + created: '2025-01-01 00:00:00', + message: [{type: 'TEXT', style: 'normal', text: 'Marked as reimbursed'}], + originalMessage: {}, + } as OnyxTypes.ReportAction; + const payAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + reportActionID: 'payActionID', + reportID: snapshotReportID, + created: '2025-01-01 00:00:01', + message: [{type: 'TEXT', style: 'normal', text: 'paid'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: snapshotReportID, amount: 100, currency: CONST.CURRENCY.USD}, + } as OnyxTypes.ReportAction; + const data = { + personalDetailsList: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${snapshotReportID}`]: { + [markedReimbursedAction.reportActionID]: markedReimbursedAction, + [payAction.reportActionID]: payAction, + }, + } as unknown as OnyxTypes.SearchResults['data']; + + // When the CHAT sections are built from the snapshot + const [sections] = SearchUIUtils.getSections({ + dateFnsLocale: undefined, + type: CONST.SEARCH.DATA_TYPES.CHAT, + data, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + rules: undefined, + conciergeReportID: undefined, + convertToDisplayString, + reportAttributesDerivedValue: {}, + }); + + // Then only the PAY action is listed, because the snapshot is used to find the sibling of the MARKED_REIMBURSED action + expect((sections as Array<{reportActionID: string}>).map((item) => item.reportActionID)).toStrictEqual([payAction.reportActionID]); + }); + it('should return getTransactionsSections result when groupBy is undefined', () => { expect( SearchUIUtils.getSections({ From e5803c52177b2d0632fc3864b78c15e2d7aa557a Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:21:16 +0700 Subject: [PATCH 331/781] update eslint --- tests/unit/Search/SearchUIUtilsTest.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index aa1df4b1bde3..f9c0da686dc9 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -2801,13 +2801,13 @@ describe('SearchUIUtils', () => { message: [{type: 'TEXT', style: 'normal', text: 'paid'}], originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUReportID: snapshotReportID, amount: 100, currency: CONST.CURRENCY.USD}, } as OnyxTypes.ReportAction; - const data = { - personalDetailsList: {}, + const data: OnyxTypes.SearchResults['data'] = { + ...searchResults.data, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${snapshotReportID}`]: { [markedReimbursedAction.reportActionID]: markedReimbursedAction, [payAction.reportActionID]: payAction, }, - } as unknown as OnyxTypes.SearchResults['data']; + }; // When the CHAT sections are built from the snapshot const [sections] = SearchUIUtils.getSections({ @@ -2826,7 +2826,11 @@ describe('SearchUIUtils', () => { }); // Then only the PAY action is listed, because the snapshot is used to find the sibling of the MARKED_REIMBURSED action - expect((sections as Array<{reportActionID: string}>).map((item) => item.reportActionID)).toStrictEqual([payAction.reportActionID]); + const snapshotReportActionIDs = sections + .filter(SearchUIUtils.isReportActionListItemType) + .filter((item) => item.reportID === snapshotReportID) + .map((item) => item.reportActionID); + expect(snapshotReportActionIDs).toStrictEqual([payAction.reportActionID]); }); it('should return getTransactionsSections result when groupBy is undefined', () => { From 50fac94a4edd2f447bb45045764778f86f944e70 Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Thu, 8 Oct 2026 05:45:52 +0000 Subject: [PATCH 332/781] Address review: shorten comments and reuse isPolicyValidForMovingExpenses for active policy Co-authored-by: huult --- src/hooks/usePolicyForMovingExpenses.ts | 22 +++++-------------- .../Search/SearchTransactionsChangeReport.tsx | 3 --- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index f60449456a35..732502112d89 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -22,10 +22,7 @@ function checkForUserPendingDelete(login: string, policy: OnyxEntry) { return isPolicyMemberWithoutPendingDelete(login, policy); } -// `policy.role` is only populated on the user's default/active policy, so fall back to the member's role in -// `employeeList` via `getPolicyRole`. Without the login-aware lookup, a workspace the user is genuinely a member -// of is rejected here while `getGroupPoliciesWhereReportCanBeCreated` (which uses `getPolicyRole`) accepts it, -// and the two disagree about whether the user has a workspace at all. +// `policy.role` is only set on the active policy, so fall back to `getPolicyRole` (matches `getGroupPoliciesWhereReportCanBeCreated`). function isPolicyMemberByRole(policy: OnyxEntry, login: string) { const role = getPolicyRole(policy, login); return !!role && (Object.values(CONST.POLICY.ROLE) as string[]).includes(role); @@ -96,10 +93,8 @@ type PolicyForMovingExpenses = { shouldSelectPolicy: boolean; shouldNavigateToUpgradePath: boolean; /** - * False while the policy collection is still being read from Onyx. Callers that *act* on the result — creating - * a report, moving transactions — must no-op until this is true, because `shouldNavigateToUpgradePath: false` - * during the read is "we don't know yet", not "this user has an eligible policy". `useCreateReport` gates the - * same way. Callers that only navigate to a picker can ignore it; they already handle an unresolved policy. + * False while policies are still loading from Onyx. Callers that act on the result (create report, move + * transactions) must no-op until true, as with `useCreateReport`. Picker-only callers can ignore it. */ arePoliciesLoaded: boolean; }; @@ -137,9 +132,7 @@ function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: // User has no eligible policy if (!resolvedPolicyID) { - // The active workspace can still be a valid destination even when the qualification pass came back - // empty, so check it before giving up. This has to run ahead of the upgrade path below, otherwise a - // perfectly valid active workspace can never rescue the user. + // The active workspace may still be valid when qualification finds nothing, so check it before the upgrade path. if (isPolicyValidForMovingExpenses(activePolicy, login, isPerDiemRequest, isTimeRequest)) { return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } @@ -152,12 +145,7 @@ function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } - if ( - activePolicy && - !isTeachersUnitePolicyID(activePolicy.id) && - (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && - (!isTimeRequest || isTimeTrackingEnabled(activePolicy)) - ) { + if (isPolicyValidForMovingExpenses(activePolicy, login, isPerDiemRequest, isTimeRequest)) { return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 96f563af465e..8433679b766a 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -235,9 +235,6 @@ function SearchTransactionsChangeReport() { return; } - // Unlike the other consumers of this hook, this path *mutates* — it creates a report and moves the selected - // transactions into it. Falling through while the policy collection is still loading would create that report - // with an undefined policy, so wait instead. `useCreateReport` no-ops the same way. if (!arePoliciesLoaded) { return; } From c517355ec7ed750d8f41ad7ad4cf862ca9aecbaa Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Thu, 8 Oct 2026 05:45:59 +0000 Subject: [PATCH 333/781] Fix: add arePoliciesLoaded to usePolicyForMovingExpenses mock in SearchActionsBarCreateButtonTest Co-authored-by: huult --- tests/ui/components/SearchActionsBarCreateButtonTest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx index d99a408e2865..be2b56651b9f 100644 --- a/tests/ui/components/SearchActionsBarCreateButtonTest.tsx +++ b/tests/ui/components/SearchActionsBarCreateButtonTest.tsx @@ -248,6 +248,7 @@ describe('SearchActionsBarCreateButton', () => { policyForMovingExpenses: MOCK_POLICY, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, + arePoliciesLoaded: true, }); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, MOCK_POLICY); From 107d3f79bbfac736435efd221f336ea67aeaecd7 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 8 Oct 2026 08:28:55 +0200 Subject: [PATCH 334/781] Explain why getMatchingFullScreenRoute is a separate module --- src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts b/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts index 83b2cf72cbc1..10ee6caa4cad 100644 --- a/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts +++ b/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts @@ -1,3 +1,8 @@ +/** + * Finds the full screen route to render under a given route. + * Split from getAdaptedStateFromPath so linkTo and Navigation can import it without pulling in ReportUtils. + * Do not import ReportUtils or anything that imports Navigation here, or Navigation rejoins the import/no-cycle graph. + */ import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; import { RHP_TO_DOMAIN, From 0c26da13b635232a0388156bcff7392ee3f7b43f Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 8 Oct 2026 16:28:58 +0700 Subject: [PATCH 335/781] address comment --- src/ROUTES.ts | 4 +- .../CategoryPicker/CategoryPickerModal.tsx | 7 +- .../sections/CategoryField.tsx | 8 ++ .../sections/CategoryFieldDropdown.tsx | 15 ++- .../sections/ExpenseFieldDropdown.tsx | 14 +- .../step/DynamicIOURequestStepCategory.tsx | 2 +- tests/unit/PolicyUtilsTest.ts | 9 ++ .../components/CategoryPickerModalTest.tsx | 2 +- .../components/ExpenseFieldDropdown.test.tsx | 36 ++++- .../CategoryFieldDropdown.test.tsx | 124 ++++++++++++++++++ 10 files changed, 205 insertions(+), 16 deletions(-) create mode 100644 tests/unit/components/MoneyRequestConfirmationList/CategoryFieldDropdown.test.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 53c0db212256..e468728a30f2 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2045,7 +2045,9 @@ const DYNAMIC_ROUTES = { }, MONEY_REQUEST_STEP_CATEGORY_CREATE: { path: 'add-category', - entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY, SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, SCREENS.MONEY_REQUEST.CREATE], + entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_CATEGORY, SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, SCREENS.MONEY_REQUEST.CREATE, SCREENS.SHARE.SUBMIT_DETAILS], + getRoute: (params: {action: IOUAction; iouType: IOUType; transactionID: string; reportID: string | undefined}) => getUrlWithParams('add-category', params), + queryParams: ['action', 'iouType', 'transactionID', 'reportID'], }, MONEY_REQUEST_ATTENDEE: { path: 'attendees', diff --git a/src/components/CategoryPicker/CategoryPickerModal.tsx b/src/components/CategoryPicker/CategoryPickerModal.tsx index e55d429e2959..1530ef768d14 100644 --- a/src/components/CategoryPicker/CategoryPickerModal.tsx +++ b/src/components/CategoryPicker/CategoryPickerModal.tsx @@ -59,6 +59,9 @@ type CategoryPickerModalProps = { /** Shows a header with an add button, for users who can add a category to the workspace */ onAddCategory?: () => void; + /** Sentry label for the add button, so each caller's presses are attributed to it */ + addCategorySentryLabel?: string; + /** Whether the pop-over may flip to the other side of the anchor when it overflows. It shifts by a whole pop-over height, so a caller that already picked the side turns this off. */ shouldSwitchPositionIfOverflow?: boolean; } & Omit; @@ -77,6 +80,7 @@ function CategoryPickerModal({ shouldFitContentHeight = false, shouldSwitchPositionIfOverflow = true, onAddCategory, + addCategorySentryLabel, }: CategoryPickerModalProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -136,7 +140,8 @@ function CategoryPickerModal({ accessibilityLabel={translate('workspace.categories.addCategory')} role={CONST.ROLE.BUTTON} onPress={onAddCategory} - sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.ADD_CATEGORY_BUTTON} + style={styles.touchableButtonImage} + sentryLabel={addCategorySentryLabel} > !!transactionID && ( ) } diff --git a/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx b/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx index d402ac95c099..e7e5eab40a76 100644 --- a/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown.tsx @@ -2,7 +2,6 @@ import CategoryPickerModal from '@components/CategoryPicker/CategoryPickerModal' import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context'; import type {ListItem} from '@components/SelectionList/types'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import useUpdateTransactionCategory from '@hooks/useUpdateTransactionCategory'; @@ -10,7 +9,6 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import {canCreateCategoryInSitu} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -32,6 +30,9 @@ type CategoryFieldDropdownProps = ExpenseFieldDropdownRenderProps & { /** Category the expense already holds, so the list can mark it */ selectedCategory: string; + + /** Whether the user can add a category to the workspace from the list */ + canAddCategory: boolean; }; /** @@ -39,9 +40,8 @@ type CategoryFieldDropdownProps = ExpenseFieldDropdownRenderProps & { * opened, keeping its Onyx subscriptions off the form's first render, and saves through the same hook the * full-page selector uses so both leave the expense in the same state. */ -function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose, ...popoverProps}: CategoryFieldDropdownProps) { - const {reportID, isEditingSplitBill, action} = useConfirmationFields(); - const currentUserLogin = useCurrentUserPersonalDetails().login; +function CategoryFieldDropdown({transactionID, policy, selectedCategory, canAddCategory, onClose, ...popoverProps}: CategoryFieldDropdownProps) { + const {reportID, isEditingSplitBill, action, iouType} = useConfirmationFields(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); @@ -61,7 +61,7 @@ function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose const openAddCategory = () => { onClose(); TransitionTracker.runAfterTransitions({ - callback: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.path)), + callback: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.getRoute({action, iouType, transactionID, reportID}))), waitForUpcomingTransition: true, }); }; @@ -79,7 +79,8 @@ function CategoryFieldDropdown({transactionID, policy, selectedCategory, onClose policyID={policy?.id} selectedCategory={selectedCategory} onSelected={handleSelected} - onAddCategory={canCreateCategoryInSitu(policy, currentUserLogin) ? openAddCategory : undefined} + onAddCategory={canAddCategory ? openAddCategory : undefined} + addCategorySentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.ADD_CATEGORY_BUTTON} /> ); } diff --git a/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown.tsx b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown.tsx index 702258694444..9b5aee21acad 100644 --- a/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown.tsx @@ -86,6 +86,9 @@ type ExpenseFieldDropdownProps = Omit void; + + /** Height of a header the list shows above its options, e.g. an add button, added to the container's limits so it still fits as many options */ + listHeaderHeight?: number; }; /** @@ -95,7 +98,7 @@ type ExpenseFieldDropdownProps = Omit setIsVisible(false); const openDropdown = () => { @@ -124,10 +130,10 @@ function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, .. anchorRef.current?.measureInWindow((x, y, width, height) => { const spaceBelow = windowHeight - (y + height + CONTAINER_GAP); const spaceAbove = y - CONTAINER_GAP - (safeAreaTop + contentHeaderHeight); - const shouldOpenAbove = spaceBelow < MAX_CONTAINER_HEIGHT && spaceAbove > spaceBelow; + const shouldOpenAbove = spaceBelow < maxContainerHeight && spaceAbove > spaceBelow; const availableHeight = (shouldOpenAbove ? spaceAbove : spaceBelow) - CONTAINER_BORDER; - if (availableHeight < MIN_USABLE_HEIGHT) { + if (availableHeight < minUsableHeight) { onPress(); return; } @@ -136,7 +142,7 @@ function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, .. horizontal: x, vertical: shouldOpenAbove ? y - CONTAINER_GAP : y + height + CONTAINER_GAP, width, - height: Math.min(MAX_CONTAINER_HEIGHT, availableHeight), + height: Math.min(maxContainerHeight, availableHeight), shouldOpenAbove, }); setHasEverOpened(true); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx index e6b935ca4414..c41679493e04 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepCategory.tsx @@ -110,7 +110,7 @@ function DynamicIOURequestStepCategory({ if (!policyID || !reportID) { return; } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.path)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY_CREATE.getRoute({action, iouType, transactionID, reportID}))); }, }, ] diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 64f68a6a689f..7d8bcdb97442 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -459,6 +459,15 @@ describe('PolicyUtils', () => { // Then it cannot, because a new category would have nowhere to show expect(canCreateCategoryInSitu(categoriesPolicy(CONST.POLICY.ROLE.ADMIN, false))).toBe(false); }); + + it('does not offer to add a category when the workspace syncs with an accounting integration', () => { + // Given an admin of a workspace whose categories come from an accounting integration + const policy = {...categoriesPolicy(CONST.POLICY.ROLE.ADMIN), connections: createMock({[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: {}})}; + + // When we check whether the category picker can offer to add one + // Then it cannot, because new categories have to be created in the integration + expect(canCreateCategoryInSitu(policy)).toBe(false); + }); }); describe('isArchivedPolicy', () => { diff --git a/tests/unit/components/CategoryPickerModalTest.tsx b/tests/unit/components/CategoryPickerModalTest.tsx index 8e3a034c7598..f9c9f6fddac0 100644 --- a/tests/unit/components/CategoryPickerModalTest.tsx +++ b/tests/unit/components/CategoryPickerModalTest.tsx @@ -43,12 +43,12 @@ describe('CategoryPickerModal', () => { // Given a user who can add categories to the workspace, as the full-page selector's "+" allows const onAddCategory = jest.fn(); renderModal(onAddCategory); + expect(screen.getByText('common.category')).toBeOnTheScreen(); // When they press the add button in the pop-over's header fireEvent.press(screen.getByLabelText('workspace.categories.addCategory')); // Then the pop-over hands over to the add-category flow, so the pop-over offers the same action the full page does - expect(screen.getByText('common.category')).toBeOnTheScreen(); expect(onAddCategory).toHaveBeenCalledTimes(1); }); diff --git a/tests/unit/components/ExpenseFieldDropdown.test.tsx b/tests/unit/components/ExpenseFieldDropdown.test.tsx index edc22989b19e..06a519d6e1fb 100644 --- a/tests/unit/components/ExpenseFieldDropdown.test.tsx +++ b/tests/unit/components/ExpenseFieldDropdown.test.tsx @@ -4,6 +4,8 @@ import type {ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConf import ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; import Text from '@components/Text'; +import getSelectionListPopoverContentHeight from '@libs/getSelectionListPopoverContentHeight'; + import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -74,7 +76,7 @@ describe('ExpenseFieldDropdown', () => { jest.restoreAllMocks(); }); - const renderField = (shouldOpenInDropdown = true, onPress: () => void = jest.fn()) => + const renderField = (shouldOpenInDropdown = true, onPress: () => void = jest.fn(), listHeaderHeight?: number) => render( { shouldOpenInDropdown={shouldOpenInDropdown} onPress={onPress} renderDropdown={renderDropdown} + listHeaderHeight={listHeaderHeight} />, ); + describe('with a header above the list', () => { + const HEADER_HEIGHT = variables.componentSizeNormal; + + it('grows the container by the header so it still fits as many options', () => { + // Given a row with plenty of room below, whose list shows a header above its options + mockRowAt(ROW_TOP); + renderField(true, jest.fn(), HEADER_HEIGHT); + + // When the row is pressed + act(() => pressRow()); + + // Then the container is taller by the header, so the header doesn't take the place of an option + expect(renderedProps?.popoverHeight).toBe(getSelectionListPopoverContentHeight({optionCount: 4}) + HEADER_HEIGHT); + }); + + it('falls back to the full page when the room left fits one option but not the header too', () => { + // Given a row with room below for one option, but not for one option and the header + const onPress = jest.fn(); + mockWindowHeight = ROW_TOP + ROW_HEIGHT + 6 + getSelectionListPopoverContentHeight({optionCount: 1}) + HEADER_HEIGHT / 2; + mockRowAt(ROW_TOP); + renderField(true, onPress, HEADER_HEIGHT); + + // When the row is pressed + act(() => pressRow()); + + // Then it opens the page, rather than a container showing the header and no categories + expect(onPress).toHaveBeenCalledTimes(1); + expect(renderCount).toBe(0); + }); + }); + it('does not mount the list until the row is pressed', () => { // Given a field whose list opens in the container anchored to the row mockRowAt(ROW_TOP); diff --git a/tests/unit/components/MoneyRequestConfirmationList/CategoryFieldDropdown.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/CategoryFieldDropdown.test.tsx new file mode 100644 index 000000000000..b0a66555d698 --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationList/CategoryFieldDropdown.test.tsx @@ -0,0 +1,124 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; +import CategoryFieldDropdown from '@components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../../../utils/collections/policies'; +import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../../../utils/waitForBatchedUpdatesWithAct'; + +const mockCallOrder: string[] = []; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(() => mockCallOrder.push('navigate')), +})); + +// Holds the post-transition work until the test runs it, standing in for the pop-over finishing its close. +let mockRunPendingTransition: (() => void) | undefined; +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + runAfterTransitions: ({callback}: {callback: () => void}): CancelHandle => { + mockRunPendingTransition = callback; + return {cancel: () => {}}; + }, +})); + +jest.mock('@hooks/useUpdateTransactionCategory', () => () => ({updateCategory: jest.fn()})); + +jest.mock('@components/CategoryPicker/CategoryPickerModal', () => { + const {Pressable, Text} = + jest.requireActual void; accessibilityLabel?: string}>>>('react-native'); + return ({onAddCategory}: {onAddCategory?: () => void}) => + onAddCategory ? ( + + addCategory + + ) : null; +}); + +const TRANSACTION_ID = '1'; +const REPORT_ID = 'reportID'; + +const renderDropdown = (onClose: () => void, canAddCategory = true) => + render( + + + , + ); + +describe('CategoryFieldDropdown', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockCallOrder.length = 0; + mockRunPendingTransition = undefined; + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('closes the pop-over before opening the add-category page', async () => { + // Given an admin with the category pop-over open + const onClose = jest.fn(() => mockCallOrder.push('close')); + renderDropdown(onClose); + await waitForBatchedUpdatesWithAct(); + + // When they press the add button + fireEvent.press(screen.getByLabelText('addCategory')); + + // Then the pop-over closes and nothing navigates until its close transition ends, since navigating while the + // modal is still up gets dropped on iOS + expect(onClose).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).not.toHaveBeenCalled(); + + mockRunPendingTransition?.(); + + // And only then the add-category page opens, carrying the expense it belongs to + expect(mockCallOrder).toEqual(['close', 'navigate']); + const route = jest.mocked(Navigation.navigate).mock.calls.at(0)?.at(0); + expect(route).toContain('add-category'); + expect(route).toContain(`transactionID=${TRANSACTION_ID}`); + expect(route).toContain(`reportID=${REPORT_ID}`); + }); + + it('offers no add button when the user cannot add a category', async () => { + // Given a user who cannot add categories to the workspace + // When the pop-over opens + renderDropdown(jest.fn(), false); + await waitForBatchedUpdatesWithAct(); + + // Then it has no add button, so the user can only pick from the existing categories + expect(screen.queryByLabelText('addCategory')).toBeNull(); + }); +}); From 7dacbabb366414fd5e55258ef308994491bdae3f Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 8 Oct 2026 16:32:14 +0700 Subject: [PATCH 336/781] address comment --- src/components/CategoryPicker/CategoryPickerModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/CategoryPicker/CategoryPickerModal.tsx b/src/components/CategoryPicker/CategoryPickerModal.tsx index 1530ef768d14..7e0837a5f61d 100644 --- a/src/components/CategoryPicker/CategoryPickerModal.tsx +++ b/src/components/CategoryPicker/CategoryPickerModal.tsx @@ -133,14 +133,14 @@ function CategoryPickerModal({ > {!!onAddCategory && ( - + {translate('common.category')} Date: Thu, 8 Oct 2026 09:59:35 +0000 Subject: [PATCH 337/781] Keep the time picker Save button visible in landscape mode Co-authored-by: Situ Chandra Shil --- src/components/TimePicker/TimePicker.tsx | 213 ++++++++++++++--------- tests/ui/components/TimePickerTest.tsx | 29 ++- 2 files changed, 154 insertions(+), 88 deletions(-) diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index 22725c72cd83..3e027311d873 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -2,6 +2,7 @@ import AmountTextInput from '@components/AmountTextInput'; import BigNumberPad from '@components/BigNumberPad'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; +import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; @@ -735,117 +736,163 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou ); - return ( - - - + const renderedInputs = ( + + { + lastPressedKey.current = e.nativeEvent.key; + }} + onChangeAmount={handleHourChange} + ref={(textInputRef) => { + hourInputRef.current = textInputRef as TextInput | null; + }} + onSelectionChange={(e) => { + setSelectionHour(e.nativeEvent.selection); + }} + style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} + containerStyle={[styles.iouAmountTextInputContainer]} + touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} + selection={selectionHour} + testID="hourInput" + /> + {CONST.COLON} + { + lastPressedKey.current = e.nativeEvent.key; + handleFocusOnBackspace(e); + }} + onChangeAmount={handleMinutesChange} + ref={(textInputRef) => { + minuteInputRef.current = textInputRef as TextInput | null; + if (!showFullFormat) { + inputCallbackRef(textInputRef as TextInput | null); + } + }} + onSelectionChange={(e) => { + setSelectionMinute(e.nativeEvent.selection); + }} + style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} + containerStyle={[styles.iouAmountTextInputContainer]} + touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} + selection={selectionMinute} + testID="minuteInput" + /> + {showFullFormat && ( + <> + {CONST.COLON} { lastPressedKey.current = e.nativeEvent.key; + handleFocusOnBackspace(e); }} - onChangeAmount={handleHourChange} + onChangeAmount={handleSecondsChange} ref={(textInputRef) => { - hourInputRef.current = textInputRef as TextInput | null; + secondInputRef.current = textInputRef as TextInput | null; }} onSelectionChange={(e) => { - setSelectionHour(e.nativeEvent.selection); + setSelectionSecond(e.nativeEvent.selection); }} style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} containerStyle={[styles.iouAmountTextInputContainer]} touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} - selection={selectionHour} - testID="hourInput" + selection={selectionSecond} + testID="secondInput" /> {CONST.COLON} { lastPressedKey.current = e.nativeEvent.key; handleFocusOnBackspace(e); }} - onChangeAmount={handleMinutesChange} + onChangeAmount={handleMillisecondsChange} ref={(textInputRef) => { - minuteInputRef.current = textInputRef as TextInput | null; - if (!showFullFormat) { + millisecondInputRef.current = textInputRef as TextInput | null; + if (showFullFormat) { inputCallbackRef(textInputRef as TextInput | null); } }} onSelectionChange={(e) => { - setSelectionMinute(e.nativeEvent.selection); + setSelectionMillisecond(e.nativeEvent.selection); }} style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} containerStyle={[styles.iouAmountTextInputContainer]} touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} - selection={selectionMinute} - testID="minuteInput" + selection={selectionMillisecond} + testID="millisecondInput" /> - {showFullFormat && ( - <> - {CONST.COLON} - { - lastPressedKey.current = e.nativeEvent.key; - handleFocusOnBackspace(e); - }} - onChangeAmount={handleSecondsChange} - ref={(textInputRef) => { - secondInputRef.current = textInputRef as TextInput | null; - }} - onSelectionChange={(e) => { - setSelectionSecond(e.nativeEvent.selection); - }} - style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} - containerStyle={[styles.iouAmountTextInputContainer]} - touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} - selection={selectionSecond} - testID="secondInput" - /> - {CONST.COLON} - { - lastPressedKey.current = e.nativeEvent.key; - handleFocusOnBackspace(e); - }} - onChangeAmount={handleMillisecondsChange} - ref={(textInputRef) => { - millisecondInputRef.current = textInputRef as TextInput | null; - if (showFullFormat) { - inputCallbackRef(textInputRef as TextInput | null); - } - }} - onSelectionChange={(e) => { - setSelectionMillisecond(e.nativeEvent.selection); - }} - style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} - containerStyle={[styles.iouAmountTextInputContainer]} - touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} - selection={selectionMillisecond} - testID="millisecondInput" - /> - + + )} + + ); + + const renderedErrorMessage = isError ? ( + + ) : ( + + ); + + const renderedSaveButton = ( + + ); + + // In landscape there isn't enough height to stack the inputs, number pad and Save button, so place the inputs next to + // the number pad inside a ScrollView and keep the Save button pinned below it. + if (isInLandscapeMode) { + return ( + + + + {renderedInputs} + {renderedErrorMessage} + {renderedAmPmButtons} + + {canUseTouchScreen && ( + + {numberPad()} + )} - + + {renderedSaveButton} + + ); + } + + return ( + + + {renderedInputs} {!canUseTouchScreen && renderedAmPmButtons} - {isError ? ( - - ) : ( - - )} + {renderedErrorMessage} {canUseTouchScreen && renderedAmPmButtons} {}, shou > {numberPad()} - + {renderedSaveButton} ); } diff --git a/tests/ui/components/TimePickerTest.tsx b/tests/ui/components/TimePickerTest.tsx index 47b162d66d10..ede337599b00 100644 --- a/tests/ui/components/TimePickerTest.tsx +++ b/tests/ui/components/TimePickerTest.tsx @@ -1,7 +1,12 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; +import {fireEvent, render, screen, within} from '@testing-library/react-native'; + +import ScrollView from '@components/ScrollView'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import TimePicker from '@src/components/TimePicker/TimePicker'; import type {TimePickerProps} from '@src/components/TimePicker/TimePicker'; +import CONST from '@src/CONST'; import type {ComponentRef, Ref} from 'react'; import type {TextInput, TextInputProps} from 'react-native'; @@ -11,6 +16,10 @@ import React, {act} from 'react'; import mockCreateMock from '../../utils/createMock'; +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); + +const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); + // Store mocked inputs by testID so we can access them in tests const mockInputs: Record> = {}; @@ -73,6 +82,10 @@ describe('TimePicker Component', () => { , ); + beforeEach(() => { + mockUseResponsiveLayout.mockReturnValue(CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE); + }); + afterEach(() => { jest.clearAllMocks(); // Clear stored mock inputs @@ -145,4 +158,18 @@ describe('TimePicker Component', () => { pressBackspaceAndExpect({hours: '00', minutes: '00'}); }); }); + + it('keeps the Save button outside the scrollable area in landscape mode', () => { + // Given a landscape screen, which is too short to stack the inputs, number pad and Save button + mockUseResponsiveLayout.mockReturnValue({...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, isInLandscapeMode: true}); + + // When the time picker is rendered + renderTimePicker({defaultValue: '2025-01-01 12:34 AM'}); + + // Then the number pad sits in a scrollable area and the Save button stays pinned outside it, so it is never pushed off-screen + const scrollView = screen.UNSAFE_getByType(ScrollView); + expect(within(scrollView).getByTestId('button_<')).toBeOnTheScreen(); + // The Save button is the only button rendered outside the scrollable area + expect(screen.getAllByRole('button')).toHaveLength(within(scrollView).getAllByRole('button').length + 1); + }); }); From fa85fbad5d9afe258fd8e23c0a7ddab51f191e1b Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Thu, 8 Oct 2026 11:05:47 +0000 Subject: [PATCH 338/781] Remove unreachable reportID guard and scope merge list Onyx subscriptions - Drop the redundant !transaction.reportID check in getTransactionsForMergingLocally; the unreported branch above handles it. - Use selectors for POLICY and REPORT in MergeTransactionsListContent so the list only keeps fields used by merge eligibility. --- src/libs/actions/MergeTransaction.ts | 4 -- .../MergeTransactionsListContent.tsx | 38 ++++++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 144bcc53e1cf..852545eaf9d8 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -223,10 +223,6 @@ function getTransactionsForMergingLocally( return areTransactionsEligibleForMerge(targetTransaction, transaction) && !isTransactionPendingDelete(transaction); } - if (!transaction.reportID) { - return false; - } - // Resolve admin status against the candidate’s own workspace. If the policy is absent from // the local cache we cannot verify access, so we exclude the candidate rather than assuming // admin rights. diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx index 7d6ebff95da4..6e1a992d249e 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx @@ -23,12 +23,12 @@ import {getAmount, getCreated, getCurrency, getDescription, getMerchant, isExpen import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MergeTransaction} from '@src/types/onyx'; +import type {MergeTransaction, Policy, Report} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import React, {useEffect} from 'react'; +import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import type {MergeTransactionListItemType} from './MergeTransactionItem'; @@ -40,6 +40,22 @@ type MergeTransactionsListContentProps = { mergeTransaction: OnyxEntry; }; +const reportsSelector = (reports: OnyxCollection): OnyxCollection => + Object.fromEntries( + Object.entries(reports ?? {}).map(([key, report]) => [ + key, + report && { + reportID: report.reportID, + type: report.type, + policyID: report.policyID, + ownerAccountID: report.ownerAccountID, + managerID: report.managerID, + stateNum: report.stateNum, + statusNum: report.statusNum, + }, + ]), + ) as OnyxCollection; + function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTransactionsListContentProps) { const illustrations = useMemoizedLazyIllustrations(['EmptyShelves']); const {translate, localeCompare} = useLocalize(); @@ -51,8 +67,20 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr const currentUserLogin = session?.email; const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const policiesSelector = useCallback( + (policies: OnyxCollection): OnyxCollection => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only the fields used by the admin check are kept + Object.fromEntries( + Object.entries(policies ?? {}).map(([key, policy]) => { + const login = currentUserLogin ?? ''; + const employee = policy?.employeeList?.[login] ?? policy?.employeeList?.[login.toLowerCase()]; + return [key, policy && {id: policy.id, role: policy.role, employeeList: employee ? {[login]: employee} : undefined}]; + }), + ) as OnyxCollection, + [currentUserLogin], + ); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesSelector}); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: reportsSelector}); const {isOffline} = useNetwork(); const {convertToDisplayString, getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); From ba5e4d7e6cfd6da3d274aa8004ba8e65ad5cbd6f Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:29:23 +0100 Subject: [PATCH 339/781] add routing for WorkspaceAgentsPage --- src/CONST/index.ts | 2 ++ src/ROUTES.ts | 9 +++++++++ src/SCREENS.ts | 1 + src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts | 1 + src/languages/en.ts | 4 ++++ .../AppNavigator/Navigators/WorkspaceSplitNavigator.tsx | 1 + .../linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 3 +++ src/libs/Navigation/types.ts | 3 +++ src/pages/workspace/WorkspaceAgentsPage.tsx | 0 src/pages/workspace/getWorkspaceMenuItems.ts | 7 +++++++ tests/unit/getWorkspaceMenuItemsTest.ts | 2 ++ 12 files changed, 34 insertions(+) create mode 100644 src/pages/workspace/WorkspaceAgentsPage.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 41376e1a296a..f44efd8e17bf 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1162,6 +1162,7 @@ const CONST = { INSIGHTS_COMPARE: 'insightsCompare', PAYMENT_HISTORY: 'paymentHistory', ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', + COMPANY_AGENTS: 'companyAgents', }, BUTTON_STATES: { DEFAULT: 'default', @@ -9883,6 +9884,7 @@ const CONST = { INITIAL: { PROFILE: 'WorkspaceInitial-Profile', MEMBERS: 'WorkspaceInitial-Members', + AGENTS: 'WorkspaceInitial-Agents', ROOMS: 'WorkspaceInitial-Rooms', REPORTS: 'WorkspaceInitial-Reports', ACCOUNTING: 'WorkspaceInitial-Accounting', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5ffce14a9771..61b640952260 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3436,6 +3436,15 @@ const ROUTES = { return `workspaces/${policyID}/members` as const; }, }, + WORKSPACE_AGENTS: { + route: 'workspaces/:policyID/agents', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_AGENTS route'); + } + return `workspaces/${policyID}/agents` as const; + }, + }, WORKSPACE_ROOMS: { route: 'workspaces/:policyID/rooms', getRoute: (policyID: string | undefined) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6c4b3ee4375f..5aaa0493fc58 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -851,6 +851,7 @@ const SCREENS = { DYNAMIC_INVOICE_FIELDS_INITIAL_LIST_VALUE: 'Dynamic_Invoice_Fields_Initial_List_Value', INVOICE_FIELDS_TYPE_SELECTOR: 'Workspace_InvoiceFields_TypeSelector', MEMBERS: 'Workspace_Members', + AGENTS: 'Workspace_Agents', ROOMS: 'Workspace_Rooms', ROOM_CREATE: 'Workspace_Room_Create', MEMBERS_IMPORT: 'Members_Import', diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index 9d62d4bc70d3..46def4936d45 100644 --- a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -24,6 +24,7 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.WORKSPACE.WORKFLOWS, SCREENS.WORKSPACE.INVOICES, SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.AGENTS, SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.CATEGORIES, diff --git a/src/languages/en.ts b/src/languages/en.ts index da4c1455f16c..ef309fef1d91 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5128,6 +5128,10 @@ const translations = { title: "You've created a workspace for your client!", description: 'Great news 🎉. Reach out to us if they need any help with the setup.', }, + agents: { + title: 'Agents', + subtitle: `Agents handle your workflows for you, so you get hours back in your day. Learn more.`, + }, mcp: { connectors: 'Connectors', connectorsSubtitle: 'Connect an AI assistant to your Expensify account.', diff --git a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx index b016c03f4ffa..a7d86b4901c6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx @@ -23,6 +23,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPageRevamp').default, [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default, [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../pages/workspace/WorkspaceMembersPage').default, + [SCREENS.WORKSPACE.AGENTS]: () => require('../../../../pages/workspace/WorkspaceAgentsPage').default, [SCREENS.WORKSPACE.ROOMS]: () => require('../../../../pages/workspace/rooms/WorkspaceRoomsPage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, [SCREENS.WORKSPACE.HR]: () => require('../../../../pages/workspace/hr/WorkspaceHRPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index e827c9827c03..7b2a3d5e20c2 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -32,6 +32,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.MEMBERS]: { path: ROUTES.WORKSPACE_MEMBERS.route, }, + [SCREENS.WORKSPACE.AGENTS]: { + path: ROUTES.WORKSPACE_AGENTS.route, + }, [SCREENS.WORKSPACE.ROOMS]: { path: ROUTES.WORKSPACE_ROOMS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 582270dda549..ea5a2ed9e9be 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -3291,6 +3291,9 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.MEMBERS]: { policyID: string; }; + [SCREENS.WORKSPACE.AGENTS]: { + policyID: string; + }; [SCREENS.WORKSPACE.ROOMS]: { policyID: string; }; diff --git a/src/pages/workspace/WorkspaceAgentsPage.tsx b/src/pages/workspace/WorkspaceAgentsPage.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index 1f80a8a00759..6b3b43a7f65a 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -210,6 +210,13 @@ function getWorkspaceMenuItems({ screenName: SCREENS.WORKSPACE.MEMBERS, sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.MEMBERS, }, + { + translationKey: 'workspace.agents.title', + icon: icons.Bot, + getRoute: () => ROUTES.WORKSPACE_AGENTS.getRoute(policyID), + screenName: SCREENS.WORKSPACE.AGENTS, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.AGENTS, + }, { translationKey: 'workspace.common.rooms', icon: icons.Hashtag, diff --git a/tests/unit/getWorkspaceMenuItemsTest.ts b/tests/unit/getWorkspaceMenuItemsTest.ts index 0be6c2a0dfd0..3f6e1c3bade4 100644 --- a/tests/unit/getWorkspaceMenuItemsTest.ts +++ b/tests/unit/getWorkspaceMenuItemsTest.ts @@ -535,6 +535,7 @@ describe('getWorkspaceMenuItems', () => { expect(items.map((item) => item.getRoute())).toEqual([ ROUTES.WORKSPACE_OVERVIEW.getRoute(policy.id), ROUTES.WORKSPACE_MEMBERS.getRoute(policy.id), + ROUTES.WORKSPACE_AGENTS.getRoute(policy.id), ROUTES.WORKSPACE_ROOMS.getRoute(policy.id), ROUTES.WORKSPACE_REPORTS.getRoute(policy.id), ROUTES.POLICY_ACCOUNTING.getRoute(policy.id), @@ -560,6 +561,7 @@ describe('getWorkspaceMenuItems', () => { expect(items.map((item) => item.screenName)).toEqual([ SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.AGENTS, SCREENS.WORKSPACE.ROOMS, SCREENS.WORKSPACE.REPORTS, SCREENS.WORKSPACE.ACCOUNTING.ROOT, From ff4aba72bbabd3a35f328eeaea41610e7a9bbd79 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:43:11 +0100 Subject: [PATCH 340/781] lock company agents behind beta --- .../SearchRouter/useNavigationSuggestions.tsx | 5 +++++ src/pages/workspace/WorkspaceInitialPage.tsx | 1 + src/pages/workspace/getWorkspaceMenuItems.ts | 21 ++++++++++++------- tests/unit/SearchRouterNavigationTest.ts | 1 + 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 17f778062bce..fadb7cd287ba 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -143,6 +143,9 @@ type BuildWorkspaceNavigationItemsParams = { /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ isRecruitingBetaEnabled: boolean; + /** Whether the company agents beta is enabled. */ + isCompanyAgentsBetaEnabled: boolean; + /** Whether navigation should use the narrow-layout Workspace flow. */ shouldUseNarrowLayout: boolean; @@ -291,6 +294,7 @@ function buildWorkspaceNavigationItems({ isOffline, isVendorMatchingBetaEnabled, isRecruitingBetaEnabled, + isCompanyAgentsBetaEnabled, shouldUseNarrowLayout, convertToDisplayString, getItemText, @@ -441,6 +445,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) isOffline: !!isOffline, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), + isCompanyAgentsBetaEnabled: isBetaEnabled(CONST.BETAS.COMPANY_AGENTS), shouldUseNarrowLayout, convertToDisplayString, getItemText: (item) => translate(item.translationKey), diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 2f252c1f6a59..63a438353ebd 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -192,6 +192,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac shouldShowRBR, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), + isCompanyAgentsBetaEnabled: isBetaEnabled(CONST.BETAS.COMPANY_AGENTS), convertToDisplayString, }).map((item) => ({ ...item, diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index 6b3b43a7f65a..8804a3a985ee 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -113,6 +113,8 @@ type GetWorkspaceMenuItemsParams = { isVendorMatchingBetaEnabled?: boolean; /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ isRecruitingBetaEnabled?: boolean; + /** Whether the company agents beta is enabled. */ + isCompanyAgentsBetaEnabled?: boolean; /** Formats the invoice account balance for its menu badge. */ convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; }; @@ -130,6 +132,7 @@ function getWorkspaceMenuItems({ shouldShowRBR = false, isVendorMatchingBetaEnabled = false, isRecruitingBetaEnabled = false, + isCompanyAgentsBetaEnabled = false, convertToDisplayString, }: GetWorkspaceMenuItemsParams): WorkspaceMenuItem[] { const canReadPolicyFeature = (policyFeature: PolicyFeature) => canMemberRead(policy, currentUserLogin ?? '', policyFeature); @@ -210,13 +213,6 @@ function getWorkspaceMenuItems({ screenName: SCREENS.WORKSPACE.MEMBERS, sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.MEMBERS, }, - { - translationKey: 'workspace.agents.title', - icon: icons.Bot, - getRoute: () => ROUTES.WORKSPACE_AGENTS.getRoute(policyID), - screenName: SCREENS.WORKSPACE.AGENTS, - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.AGENTS, - }, { translationKey: 'workspace.common.rooms', icon: icons.Hashtag, @@ -226,6 +222,17 @@ function getWorkspaceMenuItems({ }, ]; + if (isGroupPolicy(policy) && isCompanyAgentsBetaEnabled) { + const membersItemIndex = items.findIndex((item) => item.screenName === SCREENS.WORKSPACE.MEMBERS); + items.splice(membersItemIndex + 1, 0, { + translationKey: 'workspace.agents.title', + icon: icons.Bot, + getRoute: () => ROUTES.WORKSPACE_AGENTS.getRoute(policyID), + screenName: SCREENS.WORKSPACE.AGENTS, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.AGENTS, + }); + } + if (isGroupPolicy(policy) && shouldShowProtectedItems) { if (canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.REPORT_FIELDS)) { items.push({ diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 8de59663665f..1d71268c06ad 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -713,6 +713,7 @@ describe('Workspace Search Router navigation source', () => { isOffline, isVendorMatchingBetaEnabled: false, isRecruitingBetaEnabled: false, + isCompanyAgentsBetaEnabled: false, shouldUseNarrowLayout: false, convertToDisplayString: () => '$0.00', getItemText: (item) => { From a8947b33a4f499e7bbfd406bd38512288352e65b Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Thu, 8 Oct 2026 14:11:39 +0200 Subject: [PATCH 341/781] fix: mWeb tab labels truncate inside their slot on one line, clear of the selected pill next to them --- .../Navigation/NavigationTabBar/TabBarItem.tsx | 2 +- src/components/Navigation/NavigationTabBar/index.tsx | 1 + src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx | 10 ++++++++-- src/styles/index.ts | 11 ++++++++--- src/styles/variables.ts | 2 ++ 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx index 87613dbe3f72..dcf891ed9d51 100644 --- a/src/components/Navigation/NavigationTabBar/TabBarItem.tsx +++ b/src/components/Navigation/NavigationTabBar/TabBarItem.tsx @@ -38,7 +38,7 @@ function TabBarItem({icon, label, isSelected, isHovered = false, statusIndicator const iconSize = shouldUseNarrowLayout ? variables.iconFloatingTabBar : variables.iconBottomBar; return ( - + {({hovered}) => ( - + overflow: 'hidden', }, + // Stretches every tab to its slot, so a long label truncates inside the slot instead of spilling into its neighbors. + navigationTabBarItemNarrow: { + alignSelf: 'stretch', + paddingHorizontal: variables.floatingTabBarLabelInset, + }, + navigationTabBarItemSelected: { backgroundColor: theme.floatingTabBarSelectedBG, borderRadius: variables.componentBorderRadiusCircle, - // The padding matches the negative margin, so the label keeps the tab's width to wrap in, selected or not. - alignSelf: 'stretch', - paddingHorizontal: variables.floatingTabBarSelectedOverhang, + // The extra padding matches the negative margin, so the label keeps the same width selected or not. + paddingHorizontal: variables.floatingTabBarLabelInset + variables.floatingTabBarSelectedOverhang, marginHorizontal: -variables.floatingTabBarSelectedOverhang, }, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 02fa82cd4e6e..4ebc7dced2e1 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -35,6 +35,8 @@ export default { floatingTabBarHorizontalInset: 16, // How far the selected pill reaches past its tab on each side, whatever the label's length. floatingTabBarSelectedOverhang: 4, + // Space between a label's truncation edge and its tab's edge, so a neighbor's selected pill never reaches the label. + floatingTabBarLabelInset: 8, floatingTabBarBottomInset: 8, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). debugTabViewHeight: 64, From e3e2dcebc68a0c347e8dba65e753f868564b0d59 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Thu, 8 Oct 2026 14:26:47 +0200 Subject: [PATCH 342/781] fix: iOS tab labels leave room for the selected pill next to them --- .../NativeTabNavigator/useNativeTabBarOptions/index.ios.ts | 2 +- src/styles/variables.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts index e6f46ed27404..4b20eaeaa88c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -39,7 +39,7 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, d const {windowWidth} = useWindowDimensions(); // One of the tabs never has a bar item, so the bar splits its width between the others. const barItemCount = Object.keys(tabLabels).length - 1; - const labelMaxWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount; + const labelMaxWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount - variables.iosNativeTabBarLabelInset; const screenOptions: NativeBottomTabNavigationOptions = { headerShown: false, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 4ebc7dced2e1..15f28dd6fb25 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -122,6 +122,9 @@ export default { nativeTabIconLabelGap: 6, /** Space between the screen edge and the first tab of the iOS 26 floating tab bar. */ iosNativeTabBarHorizontalInset: 25, + // UITabBar sizes its items to their icons and gives the selected pill a minimum width, so a label as wide as an even + // share of the bar lets that pill reach over the label next to it. + iosNativeTabBarLabelInset: 8, /** Material's BottomNavigationView row, without the gesture inset under it. */ androidNativeTabBarHeight: 80, /** Material's bar row (androidNativeTabBarHeight) plus the 16 dp Material keeps between a FAB and the bar. */ From 51d09c1adf0bb1dac7925726e9959c072146b822 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 8 Oct 2026 15:06:12 +0200 Subject: [PATCH 343/781] beta change --- src/pages/workspace/WorkspaceOfficeLocationAddPage.tsx | 2 +- src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx | 2 +- src/pages/workspace/WorkspaceOverviewPage.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/WorkspaceOfficeLocationAddPage.tsx b/src/pages/workspace/WorkspaceOfficeLocationAddPage.tsx index d3b44b556fff..248c5afbbfe2 100644 --- a/src/pages/workspace/WorkspaceOfficeLocationAddPage.tsx +++ b/src/pages/workspace/WorkspaceOfficeLocationAddPage.tsx @@ -63,7 +63,7 @@ function WorkspaceOfficeLocationAddPage({route}: WorkspaceOfficeLocationAddPageP 0); + const shouldShowOfficeLocationsSection = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && (!readOnly || shouldShowCompanyAddressOffice || officeLocationEntries.length > 0); const shouldShowRulesDocumentSubSection = isPolicyAdmin || hasRulesDocument; const personalDetails = usePersonalDetails(); From 3ecd32cd61bab11476c2cb2beab2c0eb911692df Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:14:24 +0100 Subject: [PATCH 344/781] add useAgents hook --- src/CONST/index.ts | 1 + src/hooks/useAgents.ts | 165 ++++++++++++++++ src/pages/settings/Agents/AgentsPage.tsx | 209 +++----------------- src/pages/workspace/WorkspaceAgentsPage.tsx | 54 +++++ src/pages/workspace/withPolicy.tsx | 1 + 5 files changed, 250 insertions(+), 180 deletions(-) create mode 100644 src/hooks/useAgents.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f44efd8e17bf..a9d7ef18c3d9 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4561,6 +4561,7 @@ const CONST = { POLICY_FEATURE: { OVERVIEW: 'overview', MEMBERS: 'members', + AGENTS: 'agents', ASSIGN_ELEVATED_ROLES: 'assignElevatedRoles', WORKFLOWS: 'workflows', WORKFLOWS_APPROVALS: 'workflowsApprovals', diff --git a/src/hooks/useAgents.ts b/src/hooks/useAgents.ts new file mode 100644 index 000000000000..018cc2d772f8 --- /dev/null +++ b/src/hooks/useAgents.ts @@ -0,0 +1,165 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import {TableHandle} from '@components/Table'; +import {AgentRowData, AgentsTableColumnKey} from '@components/Tables/AgentsTable'; + +import {clearAgentDeleteError, clearAgentError, clearAgentUpdateError, deleteAgent, openAgentsPage} from '@libs/actions/Agent'; +import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; +import {getLatestError} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {PendingAction} from '@src/types/onyx/OnyxCommon'; + +import {useEffect, useRef, useState} from 'react'; +import {usePrevious} from 'victory-native'; + +import useChatWithAgent from './useChatWithAgent'; +import useCleanupSelectedOptions from './useCleanupSelectedOptions'; +import useConfirmModal from './useConfirmModal'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import useRuleBotGuardModal from './useRuleBotGuardModal'; +import useSearchBackPress from './useSearchBackPress'; +import useSwitchToDelegator from './useSwitchToDelegator'; + +const handleErrorClose = (pendingAction: PendingAction | null | undefined, accountID: number) => { + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + clearAgentError(accountID); + } else if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + clearAgentDeleteError(accountID); + } else { + clearAgentUpdateError(accountID); + } +}; + +function useAgents() { + useEffect(() => { + openAgentsPage(); + }, []); + + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const [agentPrompts] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const personalDetailsList = usePersonalDetails(); + const chatWithAgent = useChatWithAgent(); + const switchToDelegator = useSwitchToDelegator(); + const {showConfirmModal} = useConfirmModal(); + const showRuleBotGuardModal = useRuleBotGuardModal(); + const [selectedAgents, setSelectedAgents] = useState([]); + + const agents: AgentRowData[] = Object.entries(agentPrompts ?? {}).flatMap(([key, agentPrompt]) => { + const accountID = Number(key.slice(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT.length)); + const details = personalDetailsList?.[accountID]; + if (!details) { + return []; + } + const pendingAction = agentPrompt?.pendingAction; + const isPendingDeletion = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + if (!isOffline && isPendingDeletion) { + return []; + } + + const mergedErrors = { + ...getLatestError(agentPrompt?.errors ?? undefined), + ...getLatestError(agentPrompt?.nameErrors ?? undefined), + ...getLatestError(agentPrompt?.promptErrors ?? undefined), + ...getLatestError(agentPrompt?.avatarErrors ?? undefined), + }; + const rowErrors = getLatestError(mergedErrors); + + return [ + { + keyForList: String(accountID), + accountID, + displayName: details.displayName ?? details.login ?? '', + login: details.login ?? '', + pendingAction, + errors: Object.keys(rowErrors).length > 0 ? rowErrors : undefined, + disabled: isPendingDeletion, + action: () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)), + onChatPress: () => chatWithAgent(accountID), + onCopilotPress: () => switchToDelegator(details.login ?? ''), + dismissError: () => handleErrorClose(pendingAction, accountID), + }, + ]; + }); + + const tableRef = useRef>(null); + const agentKeys = agents.map((agent) => agent.keyForList); + const prevAgentKeys = usePrevious(agentKeys); + + // Highlight (and scroll to) a newly created agent's row once it appears in the table, mirroring + // the same pattern used for newly-invited workspace members (see WorkspaceMembersPage). Not + // gated on useIsFocused: on wide layouts this page is the central pane of a split navigator and + // stays visible (but unfocused per react-navigation) while the new agent's DM opens in the RHP. + useEffect(() => { + const newAgentKeys = agentKeys.filter((key) => !prevAgentKeys.includes(key)); + if (!newAgentKeys.length) { + return; + } + + const tableAgents = tableRef.current?.getProcessedData() ?? []; + const newAgentIndex = tableAgents.findIndex((agent) => newAgentKeys.includes(agent.keyForList)); + if (newAgentIndex !== -1) { + tableRef.current?.scrollToIndex({ + index: newAgentIndex, + animated: false, + viewPosition: 0.5, + }); + } + tableRef.current?.highlightItems(newAgentKeys); + }, [agentKeys, prevAgentKeys]); + + const agentsByAccountID = new Map(agents.map((agent) => [agent.keyForList, agent])); + const selectedAgentKeys = selectedAgents.filter((accountIDString) => { + const agent = agentsByAccountID.get(accountIDString); + return !!agent && agent.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + }); + + const clearSelectedAgents = () => setSelectedAgents((prevSelectedAgents) => (prevSelectedAgents.length > 0 ? [] : prevSelectedAgents)); + useCleanupSelectedOptions(clearSelectedAgents); + useSearchBackPress({ + onClearSelection: clearSelectedAgents, + onNavigationCallBack: () => Navigation.goBack(), + }); + + const removeSelectedAgents = () => { + for (const accountIDString of selectedAgentKeys) { + const accountID = Number(accountIDString); + const agentLogin = personalDetailsList?.[accountID]?.login; + deleteAgent(accountID, agentLogin, allPolicies, false); + } + clearSelectedAgents(); + }; + const askForConfirmationToDelete = async () => { + const ruleBotEnforcedPolicy = selectedAgentKeys.map((accountIDString) => getRuleBotEnforcedPolicy(Number(accountIDString), allPolicies)).find(Boolean); + if (ruleBotEnforcedPolicy) { + showRuleBotGuardModal('deleteAgent', ruleBotEnforcedPolicy.id); + return; + } + const result = await showConfirmModal({ + title: translate('agentsPage.deleteAgentsTitle', {count: selectedAgentKeys.length}), + prompt: translate('agentsPage.deleteAgentsMessage', {count: selectedAgentKeys.length}), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldHandleNavigationBack: false, + }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + + removeSelectedAgents(); + }; + + return {agents, selectedAgentKeys, setSelectedAgents, clearSelectedAgents, askForConfirmationToDelete, tableRef}; +} + +export default useAgents; diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index deb62181cbee..5978edf14847 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -3,194 +3,54 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; -import type {TableHandle} from '@components/Table'; -import type {AgentRowData, AgentsTableColumnKey} from '@components/Tables/AgentsTable'; import AgentsTable from '@components/Tables/AgentsTable'; -import useChatWithAgent from '@hooks/useChatWithAgent'; -import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; -import useConfirmModal from '@hooks/useConfirmModal'; +import useAgents from '@hooks/useAgents'; import useDocumentTitle from '@hooks/useDocumentTitle'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; -import useSearchBackPress from '@hooks/useSearchBackPress'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; -import useSwitchToDelegator from '@hooks/useSwitchToDelegator'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; -import {getLatestError} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {clearAgentDeleteError, clearAgentError, clearAgentUpdateError, deleteAgent, openAgentsPage} from '@userActions/Agent'; - import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; function AgentsPage() { const {translate} = useLocalize(); const styles = useThemeStyles(); const {pageGutter} = useLayoutSpacing(); - const {isOffline} = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Trashcan']); - const chatWithAgent = useChatWithAgent(); - const switchToDelegator = useSwitchToDelegator(); - const {showConfirmModal} = useConfirmModal(); - const showRuleBotGuardModal = useRuleBotGuardModal(); - const [selectedAgents, setSelectedAgents] = useState([]); - const isMobileSelectionModeEnabled = useMobileSelectionMode(); - useDocumentTitle(translate('agentsPage.title')); - - const [agentPrompts] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const personalDetailsList = usePersonalDetails(); + const {agents, selectedAgentKeys, setSelectedAgents, clearSelectedAgents, askForConfirmationToDelete, tableRef} = useAgents(); + const hasAgents = agents.length > 0; const canSelectMultiple = shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true; + const shouldShowBulkActionsButton = shouldUseNarrowLayout ? canSelectMultiple : selectedAgentKeys.length > 0; - useEffect(() => { - openAgentsPage(); - }, []); - - const handleErrorClose = (pendingAction: PendingAction | null | undefined, accountID: number) => { - if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - clearAgentError(accountID); - } else if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - clearAgentDeleteError(accountID); - } else { - clearAgentUpdateError(accountID); - } - }; - - const agents: AgentRowData[] = Object.entries(agentPrompts ?? {}).flatMap(([key, agentPrompt]) => { - const accountID = Number(key.slice(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT.length)); - const details = personalDetailsList?.[accountID]; - if (!details) { - return []; - } - const pendingAction = agentPrompt?.pendingAction; - const isPendingDeletion = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - - if (!isOffline && isPendingDeletion) { - return []; - } - - const mergedErrors = { - ...getLatestError(agentPrompt?.errors ?? undefined), - ...getLatestError(agentPrompt?.nameErrors ?? undefined), - ...getLatestError(agentPrompt?.promptErrors ?? undefined), - ...getLatestError(agentPrompt?.avatarErrors ?? undefined), - }; - const rowErrors = getLatestError(mergedErrors); - - return [ - { - keyForList: String(accountID), - accountID, - displayName: details.displayName ?? details.login ?? '', - login: details.login ?? '', - pendingAction, - errors: Object.keys(rowErrors).length > 0 ? rowErrors : undefined, - disabled: isPendingDeletion, - action: () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)), - onChatPress: () => chatWithAgent(accountID), - onCopilotPress: () => switchToDelegator(details.login ?? ''), - dismissError: () => handleErrorClose(pendingAction, accountID), - }, - ]; - }); - - const tableRef = useRef>(null); - const agentKeys = agents.map((agent) => agent.keyForList); - const prevAgentKeys = usePrevious(agentKeys); - - // Highlight (and scroll to) a newly created agent's row once it appears in the table, mirroring - // the same pattern used for newly-invited workspace members (see WorkspaceMembersPage). Not - // gated on useIsFocused: on wide layouts this page is the central pane of a split navigator and - // stays visible (but unfocused per react-navigation) while the new agent's DM opens in the RHP. - useEffect(() => { - const newAgentKeys = agentKeys.filter((key) => !prevAgentKeys.includes(key)); - if (!newAgentKeys.length) { - return; - } - - const tableAgents = tableRef.current?.getProcessedData() ?? []; - const newAgentIndex = tableAgents.findIndex((agent) => newAgentKeys.includes(agent.keyForList)); - if (newAgentIndex !== -1) { - tableRef.current?.scrollToIndex({ - index: newAgentIndex, - animated: false, - viewPosition: 0.5, - }); - } - tableRef.current?.highlightItems(newAgentKeys); - }, [agentKeys, prevAgentKeys]); - - const agentsByAccountID = new Map(agents.map((agent) => [agent.keyForList, agent])); - const selectedAgentKeys = selectedAgents.filter((accountIDString) => { - const agent = agentsByAccountID.get(accountIDString); - return !!agent && agent.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - }); - - const clearSelectedAgents = () => { - setSelectedAgents((prevSelectedAgents) => (prevSelectedAgents.length > 0 ? [] : prevSelectedAgents)); - }; - - useCleanupSelectedOptions(clearSelectedAgents); - - useSearchBackPress({ - onClearSelection: clearSelectedAgents, - onNavigationCallBack: () => Navigation.goBack(), - }); - - const removeSelectedAgents = () => { - for (const accountIDString of selectedAgentKeys) { - const accountID = Number(accountIDString); - const agentLogin = personalDetailsList?.[accountID]?.login; - deleteAgent(accountID, agentLogin, allPolicies, false); - } - clearSelectedAgents(); - }; - - const askForConfirmationToDelete = async () => { - const ruleBotEnforcedPolicy = selectedAgentKeys.map((accountIDString) => getRuleBotEnforcedPolicy(Number(accountIDString), allPolicies)).find(Boolean); - if (ruleBotEnforcedPolicy) { - showRuleBotGuardModal('deleteAgent', ruleBotEnforcedPolicy.id); - return; - } - const result = await showConfirmModal({ - title: translate('agentsPage.deleteAgentsTitle', {count: selectedAgentKeys.length}), - prompt: translate('agentsPage.deleteAgentsMessage', {count: selectedAgentKeys.length}), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - shouldHandleNavigationBack: false, - }); - - if (result.action !== ModalActions.CONFIRM) { - return; - } + useDocumentTitle(translate('agentsPage.title')); - removeSelectedAgents(); - }; + const newAgentButton = ( + + ); const bulkActionsButtonOptions: Array>> = [ { @@ -202,20 +62,6 @@ function AgentsPage() { }, ]; - const hasAgents = agents.length > 0; - const shouldShowBulkActionsButton = shouldUseNarrowLayout ? canSelectMultiple : selectedAgentKeys.length > 0; - const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; - - const newAgentButton = ( - - ); - const headerButtons = shouldShowBulkActionsButton ? ( > variant={CONST.BUTTON_VARIANT.SUCCESS} @@ -226,7 +72,6 @@ function AgentsPage() { options={bulkActionsButtonOptions} isSplitButton={false} isDisabled={!selectedAgentKeys.length} - testID="AgentsPage-header-dropdown-menu-button" /> ) : ( newAgentButton @@ -243,6 +88,17 @@ function AgentsPage() { ); + const headerContent = shouldDisplayButtonsInSeparateLine ? undefined : headerButtons; + + const onBackButtonPress = () => { + if (isMobileSelectionModeEnabled) { + clearSelectedAgents(); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }; + return ( { - if (isMobileSelectionModeEnabled) { - clearSelectedAgents(); - turnOffMobileSelectionMode(); - return; - } - Navigation.goBack(); - }} + onBackButtonPress={onBackButtonPress} shouldShowBackButton={shouldUseNarrowLayout} shouldUseHeadlineHeader={!selectionModeHeader} shouldDisplaySearchRouter shouldDisplayHelpButton title={selectionModeHeader ? translate('common.selectMultiple') : translate('agentsPage.title')} > - {!shouldDisplayButtonsInSeparateLine && headerButtons} + {headerContent} ; +function WorkspaceAgentsPage({route}: WorkspaceAgentsPageProps) { + const policy = usePolicy(route.params.policyID); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + const isCompanyAgentsBetaEnabled = isBetaEnabled(CONST.BETAS.COMPANY_AGENTS); + + useWorkspaceDocumentTitle(policy?.name, 'workspace.agents.title'); + + return ( + + + {(policyID?: string) => } + + + ); +} + +export default WorkspaceAgentsPage; diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index b82d3e72f03e..996b9f2a8544 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -24,6 +24,7 @@ type PolicyRouteName = | typeof SCREENS.WORKSPACE.PROFILE | typeof SCREENS.WORKSPACE.MORE_FEATURES | typeof SCREENS.WORKSPACE.MEMBERS + | typeof SCREENS.WORKSPACE.AGENTS | typeof SCREENS.WORKSPACE.EXPENSIFY_CARD | typeof SCREENS.WORKSPACE.COMPANY_CARDS | typeof SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE From 8c50034d164cc52f96f9116dec92917432cf6f07 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Wed, 7 Oct 2026 16:06:18 +0200 Subject: [PATCH 345/781] Show tooltips on mobile devices --- .../Charts/PieChart/PieChartContent.tsx | 27 +++++++- .../Charts/hooks/useChartInteractions.ts | 68 ++++++++++++++++--- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index efc9c2a6654f..4bf58a685f5b 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -14,7 +14,7 @@ import type {LayoutChangeEvent} from 'react-native'; import React, {useState} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {Gesture, GestureDetector, PointerType} from 'react-native-gesture-handler'; import Animated, {useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; import {Pie, PolarChart} from 'victory-native'; @@ -108,12 +108,21 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice const onChartMoved = (deltaX: number, deltaY: number) => scheduleOnUI(handleChartMoved, deltaX, deltaY); + const toggleActiveSlice = (x: number, y: number) => { + const {centerX, centerY} = pieGeometry; + const sliceIndex = findSliceAtPosition(x, y, centerX, centerY, radius, innerRadius, processedSlices); + setActiveSliceIndex(sliceIndex === activeSliceIndex ? -1 : sliceIndex); + }; + // Hover gesture const hoverGesture = () => Gesture.Hover() .onBegin((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } isHovering.set(true); cursorX.set(e.x); cursorY.set(e.y); @@ -123,24 +132,36 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice .onUpdate((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } cursorX.set(e.x); cursorY.set(e.y); tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP}); scheduleOnRN(updateActiveSlice, e.x, e.y); }) - .onEnd(() => { + .onEnd((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } isHovering.set(false); scheduleOnRN(setActiveSliceIndex, -1); scheduleOnRN(setIsHoveringOverPie, false); }); - // Tap gesture for click/tap navigation + // Tap gesture: touch toggles the tooltip, other pointers navigate on click const tapGesture = () => Gesture.Tap().onEnd((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP}); + scheduleOnRN(toggleActiveSlice, e.x, e.y); + return; + } + const {centerX, centerY} = pieGeometry; const sliceIndex = findSliceAtPosition(e.x, e.y, centerX, centerY, radius, innerRadius, processedSlices); diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index b15420ef881d..ae15e365515f 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -1,7 +1,7 @@ import type {SharedValue} from 'react-native-reanimated'; import {useCallback} from 'react'; -import {Gesture} from 'react-native-gesture-handler'; +import {Gesture, PointerType} from 'react-native-gesture-handler'; import {useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; @@ -184,6 +184,9 @@ function useChartInteractions({ const isCursorOverClickable = useSharedValue(false); const isTooltipActive = useSharedValue(false); + /** True while a tooltip opened by a touch tap stays visible until the next tap */ + const isTouchPinned = useSharedValue(false); + /** * Called by chart content from handleScaleChange to populate canvas positions. * Must be called with the positions derived from the current d3 scale. @@ -339,11 +342,26 @@ function useChartInteractions({ * customGestures prop, because Victory's internal GestureHandler view only covers * the plot area and would drop events from the label area. */ + const hideTooltip = () => { + 'worklet'; + + chartInteractionState.isActive.set(false); + isTouchPinned.set(false); + isCursorOverTarget.set(false); + isCursorOverClickable.set(false); + isTooltipActive.set(false); + }; + const hoverGesture = () => Gesture.Hover() .onBegin((e) => { 'worklet'; + // Touch on web can start a hover through pointerenter, which would fight the tap-to-show tooltip + if (e.pointerType === PointerType.TOUCH) { + return; + } + isTouchPinned.set(false); const cursorX = normalizeChartCoordinate(e.x, coordinateScale); const cursorY = normalizeChartCoordinate(e.y, coordinateScale); chartInteractionState.isActive.set(true); @@ -358,20 +376,50 @@ function useChartInteractions({ .onUpdate((e) => { 'worklet'; + if (e.pointerType === PointerType.TOUCH) { + return; + } updateHoverAt(normalizeChartCoordinate(e.x, coordinateScale), normalizeChartCoordinate(e.y, coordinateScale)); }) - .onEnd(() => { + .onEnd((e) => { 'worklet'; - chartInteractionState.isActive.set(false); - isCursorOverTarget.set(false); - isCursorOverClickable.set(false); - isTooltipActive.set(false); + if (e.pointerType === PointerType.TOUCH) { + return; + } + hideTooltip(); }); /** - * Tap gesture. Resolves the nearest data point entirely on the UI thread, - * then schedules handlePress on the JS thread if the cursor is over the target. + * Touch taps toggle the tooltip instead of drilling in, since touch devices have no hover. + * Tapping a new target shows its tooltip; tapping the same target again or empty space hides it. + */ + const handleTouchTap = (cursorX: number, cursorY: number) => { + 'worklet'; + + const previousIndex = chartInteractionState.matchedIndex.get(); + const wasPinned = isTouchPinned.get(); + const bottom = chartBottom?.get() ?? cursorY; + const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; + const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); + if (targetIndex < 0 || (wasPinned && targetIndex === previousIndex)) { + hideTooltip(); + return; + } + + chartInteractionState.isActive.set(true); + applyTargetIndex(targetIndex); + const isOverTarget = updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); + if (!isOverTarget) { + hideTooltip(); + return; + } + isTouchPinned.set(true); + }; + + /** + * Tap gesture. Resolves the nearest data point entirely on the UI thread. + * Touch taps toggle the tooltip; other pointers schedule handlePress on the JS thread if the cursor is over the target. */ const tapGesture = () => Gesture.Tap().onEnd((e) => { @@ -381,6 +429,10 @@ function useChartInteractions({ const cursorY = normalizeChartCoordinate(e.y, coordinateScale); chartInteractionState.cursor.x.set(cursorX); chartInteractionState.cursor.y.set(cursorY); + if (e.pointerType === PointerType.TOUCH) { + handleTouchTap(cursorX, cursorY); + return; + } const ox = pointOX.get(); const oy = pointOY.get(); const idx = getResolvedTargetIndex(cursorX, cursorY, cursorX); From 8ddb8dbd18ba275460c30c34223e18c773ee5d85 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 8 Oct 2026 14:58:53 +0200 Subject: [PATCH 346/781] Fix comments --- .../Charts/hooks/useChartInteractions.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index ae15e365515f..efe84fd6500f 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -183,8 +183,6 @@ function useChartInteractions({ const isCursorOverTarget = useSharedValue(false); const isCursorOverClickable = useSharedValue(false); const isTooltipActive = useSharedValue(false); - - /** True while a tooltip opened by a touch tap stays visible until the next tap */ const isTouchPinned = useSharedValue(false); /** @@ -334,14 +332,6 @@ function useChartInteractions({ const onChartMoved = (deltaX: number, deltaY: number) => scheduleOnUI(handleChartMoved, deltaX, deltaY); - /** - * Hover gesture to be placed on the full-height outer container (chart + label area). - * Clamps the y coordinate to chartBottom before passing to Victory so that hovering - * over x-axis labels below the plot area still resolves the nearest data point. - * This gesture is returned separately and must NOT be passed to CartesianChart's - * customGestures prop, because Victory's internal GestureHandler view only covers - * the plot area and would drop events from the label area. - */ const hideTooltip = () => { 'worklet'; @@ -352,12 +342,19 @@ function useChartInteractions({ isTooltipActive.set(false); }; + /** + * Hover gesture to be placed on the full-height outer container (chart + label area). + * Clamps the y coordinate to chartBottom before passing to Victory so that hovering + * over x-axis labels below the plot area still resolves the nearest data point. + * This gesture is returned separately and must NOT be passed to CartesianChart's + * customGestures prop, because Victory's internal GestureHandler view only covers + * the plot area and would drop events from the label area. + */ const hoverGesture = () => Gesture.Hover() .onBegin((e) => { 'worklet'; - // Touch on web can start a hover through pointerenter, which would fight the tap-to-show tooltip if (e.pointerType === PointerType.TOUCH) { return; } @@ -392,7 +389,7 @@ function useChartInteractions({ /** * Touch taps toggle the tooltip instead of drilling in, since touch devices have no hover. - * Tapping a new target shows its tooltip; tapping the same target again or empty space hides it. + * Tapping a new target shows its tooltip and tapping the same target again or empty space hides it. */ const handleTouchTap = (cursorX: number, cursorY: number) => { 'worklet'; From d6cd599fa5ff9c9ccdd1f5d88b1f6ee39279f90f Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:11:09 +0100 Subject: [PATCH 347/781] update WorkspaceAgentsPage to use useAgents hook --- src/pages/workspace/WorkspaceAgentsPage.tsx | 97 ++++++++++++++++++++- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/WorkspaceAgentsPage.tsx b/src/pages/workspace/WorkspaceAgentsPage.tsx index e3eca862094d..84b995d02a62 100644 --- a/src/pages/workspace/WorkspaceAgentsPage.tsx +++ b/src/pages/workspace/WorkspaceAgentsPage.tsx @@ -1,11 +1,23 @@ +import Button from '@components/Button'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import RenderHTML from '@components/RenderHTML'; +import AgentsTable from '@components/Tables/AgentsTable'; + +import useAgents from '@hooks/useAgents'; +import useLayoutSpacing from '@hooks/useLayoutSpacing'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; -import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -14,7 +26,9 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import DeepValueOf from '@src/types/utils/DeepValueOf'; import React from 'react'; import {View} from 'react-native'; @@ -24,12 +38,77 @@ function WorkspaceAgentsPage({route}: WorkspaceAgentsPageProps) { const policy = usePolicy(route.params.policyID); const {translate} = useLocalize(); const styles = useThemeStyles(); + const {pageGutter} = useLayoutSpacing(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isBetaEnabled} = usePermissions(); const isCompanyAgentsBetaEnabled = isBetaEnabled(CONST.BETAS.COMPANY_AGENTS); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Trashcan']); + const {agents, selectedAgentKeys, setSelectedAgents, clearSelectedAgents, askForConfirmationToDelete, tableRef} = useAgents(); + const hasAgents = agents.length > 0; + const canSelectMultiple = shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true; + const shouldShowBulkActionsButton = shouldUseNarrowLayout ? canSelectMultiple : selectedAgentKeys.length > 0; useWorkspaceDocumentTitle(policy?.name, 'workspace.agents.title'); + const newAgentButton = ( + + ); + + const bulkActionsButtonOptions: Array>> = [ + { + text: translate('agentsPage.deleteAgentsTitle', {count: selectedAgentKeys.length}), + value: CONST.AGENTS.BULK_ACTION_TYPES.DELETE, + icon: icons.Trashcan, + shouldSkipFocusRestore: true, + onSelected: askForConfirmationToDelete, + }, + ]; + + const headerButtons = shouldShowBulkActionsButton ? ( + > + variant={CONST.BUTTON_VARIANT.SUCCESS} + shouldAlwaysShowDropdownMenu + customText={translate('workspace.common.selected', {count: selectedAgentKeys.length})} + size={CONST.BUTTON_SIZE.MEDIUM} + onPress={() => null} + options={bulkActionsButtonOptions} + isSplitButton={false} + isDisabled={!selectedAgentKeys.length} + /> + ) : ( + newAgentButton + ); + const agentsTableHeaderComponent = ( + <> + {shouldDisplayButtonsInSeparateLine && {headerButtons}} + {hasAgents && ( + + + + )} + + ); + + const headerContent = shouldDisplayButtonsInSeparateLine ? undefined : headerButtons; + + const onBackButtonPress = () => { + if (isMobileSelectionModeEnabled) { + clearSelectedAgents(); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }; + return ( - {(policyID?: string) => } + {(policyID?: string) => ( + + )} ); From 36fc0f6e118da4d0821c90b25ee7f9482728bbca Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Thu, 8 Oct 2026 16:39:22 +0200 Subject: [PATCH 348/781] fix: show Try again when tracking a scanned receipt fails. --- .../MoneyRequestReceiptView.tsx | 1 + .../buildRetryPayload.ts | 2 +- .../buildTrackExpenseRetryPayload.ts | 107 ++++++++++++++++++ src/libs/ReceiptUploadRetryHandler/index.ts | 14 ++- src/libs/ReceiptUploadRetryHandler/types.ts | 4 +- src/libs/actions/IOU/TrackExpense.ts | 13 ++- .../IOU/types/CreateTrackExpenseParams.ts | 2 + tests/unit/ReceiptUploadRetryHandlerTest.ts | 1 + 8 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index 6b4862cd75cd..1b1cfad7d947 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -453,6 +453,7 @@ function MoneyRequestReceiptView({ transactionThreadReport, transactionViolations: rawTransactionViolations, currentUserPersonalDetails: currentUserPersonalDetail, + introSelected, } : undefined; diff --git a/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts b/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts index 46f1bba80d55..edd253f59870 100644 --- a/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts +++ b/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts @@ -162,4 +162,4 @@ function buildRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject } export default buildRetryPayload; -export {canBuildRetryPayload}; +export {canBuildRetryPayload, getCurrentUserAccountID, getMerchantForRetry, isRetryableFlow, resolveParticipant}; diff --git a/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts b/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts new file mode 100644 index 000000000000..f8391071c563 --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts @@ -0,0 +1,107 @@ +/** Decides whether a failed `TrackExpense` can be retried and rebuilds the call from Onyx. */ +import {getTransactionDetails, isSelfDM} from '@libs/ReportUtils'; +import {getIsFromGlobalCreate} from '@libs/TransactionUtils'; + +import {getAllReports, getCurrentUserPersonalDetails} from '@userActions/IOU'; +import type {CreateTrackExpenseParams} from '@userActions/IOU/types/CreateTrackExpenseParams'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Receipt} from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import type {ReceiptRetryContext} from './types'; + +import {getCurrentUserAccountID, getMerchantForRetry, isRetryableFlow, resolveParticipant} from './buildRetryPayload'; + +function canBuildTrackExpenseRetryPayload(context: ReceiptRetryContext): boolean { + const {transaction, iouReport, receiptError} = context; + if (receiptError.action !== CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE || !isSelfDM(iouReport)) { + return false; + } + return isRetryableFlow(context) && !!iouReport?.reportID && !!transaction?.transactionID && !!resolveParticipant(iouReport); +} + +/** Rebuilds the `TrackExpense` call behind a failed receipt upload from the records the failure left in Onyx. */ +function buildTrackExpenseRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject): CreateTrackExpenseParams | undefined { + const { + transaction, + iouReport, + iouActionID, + transactionThreadReportID, + policyParams, + rules, + conciergeReportID, + isSelfTourViewed, + isASAPSubmitBetaEnabled, + introSelected, + delegateAccountID, + getCurrencyDecimals, + } = context; + const participant = resolveParticipant(iouReport); + if (!canBuildTrackExpenseRetryPayload(context) || !transaction || !iouReport || !participant) { + return undefined; + } + + const currentUser = getCurrentUserPersonalDetails(); + // Unreported amounts are stored negated; this returns them positive, as `trackExpense` expects. + const details = getTransactionDetails(transaction); + if (!details) { + return undefined; + } + const receipt: Receipt = {...receiptFile, source: context.receiptError.source, state: CONST.IOU.RECEIPT_STATE.SCAN_READY}; + + return { + report: iouReport, + isDraftPolicy: false, + isDraftChatReport: false, + participantParams: { + payeeEmail: currentUser?.login, + payeeAccountID: getCurrentUserAccountID(), + participant, + }, + policyParams, + transactionParams: { + amount: details.amount, + currency: details.currency, + created: details.created, + merchant: getMerchantForRetry(details.merchant), + comment: details.comment, + category: details.category, + tag: details.tag, + taxCode: details.taxCode, + taxAmount: details.taxAmount, + billable: details.billable, + reimbursable: details.reimbursable, + // Read from the transaction because `details` widens this to `string | Attendee[]`. + attendees: transaction.comment?.attendees, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + receipt, + }, + // Same ID, so if the first attempt reached Auth the retry gets `Transaction already created.` instead of a second expense. + optimisticTransactionID: transaction.transactionID, + // Without these two, the retry creates a second IOU action and thread for the same transaction. + currentReportActionID: iouActionID, + existingTransactionThreadReportID: transactionThreadReportID, + existingTransaction: transaction, + isASAPSubmitBetaEnabled, + currentUser: { + accountID: getCurrentUserAccountID(), + email: currentUser?.login ?? '', + }, + introSelected, + conciergeChat: conciergeReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`] : undefined, + quickAction: undefined, + recentWaypoints: [], + isSelfTourViewed, + // Only read when a draft chat report builds a new workspace, which the create path behind this error never does. + currentUserLocalCurrency: undefined, + delegateAccountID, + reportActionsList: undefined, + getCurrencyDecimals, + rules, + }; +} + +export default buildTrackExpenseRetryPayload; +export {canBuildTrackExpenseRetryPayload}; diff --git a/src/libs/ReceiptUploadRetryHandler/index.ts b/src/libs/ReceiptUploadRetryHandler/index.ts index e3bc6939f3d0..d1113692007e 100644 --- a/src/libs/ReceiptUploadRetryHandler/index.ts +++ b/src/libs/ReceiptUploadRetryHandler/index.ts @@ -2,7 +2,7 @@ import Log from '@libs/Log'; import {replaceReceipt} from '@userActions/IOU/Receipt'; -import {requestMoney} from '@userActions/IOU/TrackExpense'; +import {requestMoney, trackExpense} from '@userActions/IOU/TrackExpense'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -11,6 +11,7 @@ import type {ReceiptRetryContext, RetryOutcome} from './types'; import buildReplaceReceiptRetryPayload, {canBuildReplaceReceiptRetryPayload} from './buildReplaceReceiptRetryPayload'; import buildRetryPayload, {canBuildRetryPayload} from './buildRetryPayload'; +import buildTrackExpenseRetryPayload, {canBuildTrackExpenseRetryPayload} from './buildTrackExpenseRetryPayload'; import resolveReceiptFile from './resolveReceiptFile'; type RetryFlow = { @@ -41,6 +42,17 @@ const RETRY_FLOWS: Partial> = { return true; }, }, + [CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE]: { + canBuild: canBuildTrackExpenseRetryPayload, + dispatch: (context, receiptFile) => { + const payload = buildTrackExpenseRetryPayload(context, receiptFile); + if (!payload) { + return false; + } + trackExpense({...payload, isRetry: true, shouldPlaySound: false}); + return true; + }, + }, }; function getRetryFlow(context: ReceiptRetryContext): RetryFlow | undefined { diff --git a/src/libs/ReceiptUploadRetryHandler/types.ts b/src/libs/ReceiptUploadRetryHandler/types.ts index 4e93abee5b49..abdcd1dc38be 100644 --- a/src/libs/ReceiptUploadRetryHandler/types.ts +++ b/src/libs/ReceiptUploadRetryHandler/types.ts @@ -4,7 +4,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type BasePolicyParams from '@userActions/IOU/types/BasePolicyParams'; -import type {Report, Rule, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {IntroSelected, Report, Rule, Transaction, TransactionViolations} from '@src/types/onyx'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -53,6 +53,8 @@ type ReceiptRetryContext = { transactionViolations: OnyxEntry; currentUserPersonalDetails: CurrentUserPersonalDetails; + + introSelected: OnyxEntry; }; type RetryOutcome = 'dispatched' | 'fileMissing' | 'payloadIncomplete' | 'unsupportedAction' | 'dispatchFailed'; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 9d460ac14b93..bfb2eeac05db 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -192,6 +192,8 @@ type GetTrackExpenseInformationParams = { existingTransaction?: OnyxEntry; existingTransactionID?: string; optimisticTransactionID?: string; + currentReportActionID?: string; + existingTransactionThreadReportID?: string; participantParams: GetTrackExpenseInformationParticipantParams; policyParams: BasePolicyParams; transactionParams: GetTrackExpenseInformationTransactionParams; @@ -426,6 +428,7 @@ function buildOnyxDataForTrackExpense({ value: { ...transactionThreadReport, pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + errorFields: {createChat: null}, }, }, { @@ -881,6 +884,8 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T existingTransaction, existingTransactionID, optimisticTransactionID: providedOptimisticTransactionID, + currentReportActionID, + existingTransactionThreadReportID, participantParams, policyParams, transactionParams, @@ -1178,8 +1183,9 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T participants: [participant], transactionID: optimisticTransaction.transactionID, isPersonalTrackingExpense: !shouldUseMoneyReport, - existingTransactionThreadReportID: linkedTrackedExpenseReportAction?.childReportID, + existingTransactionThreadReportID: existingTransactionThreadReportID ?? linkedTrackedExpenseReportAction?.childReportID, linkedTrackedExpenseReportAction, + reportActionID: currentReportActionID, currentUserAccountID: currentUserAccountIDParam, delegateAccountIDParam: delegateAccountID, }); @@ -1214,6 +1220,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T threadReport: optimisticTransactionThread ?? {}, }, shouldCreateNewMoneyRequestReport, + existingTransactionThreadReportID, actionableTrackExpenseWhisper, retryParams, isASAPSubmitBetaEnabled, @@ -2488,6 +2495,8 @@ function trackExpense(params: CreateTrackExpenseParams) { shouldPlaySound = true, optimisticChatReportID, optimisticTransactionID, + currentReportActionID, + existingTransactionThreadReportID, isASAPSubmitBetaEnabled, currentUser, introSelected, @@ -2625,6 +2634,8 @@ function trackExpense(params: CreateTrackExpenseParams) { moneyRequestReportID, existingTransaction, optimisticTransactionID, + currentReportActionID, + existingTransactionThreadReportID, existingTransactionID: isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && isMoneyRequestAction(linkedTrackedExpenseReportAction) ? getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID diff --git a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts index 995614776d6a..997485c6fcd5 100644 --- a/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts +++ b/src/libs/actions/IOU/types/CreateTrackExpenseParams.ts @@ -39,6 +39,8 @@ type CreateTrackExpenseParams = { draftTransactionIDs?: string[]; optimisticChatReportID?: string; optimisticTransactionID?: string; + currentReportActionID?: string; + existingTransactionThreadReportID?: string; isASAPSubmitBetaEnabled: boolean; currentUser: CurrentUser; introSelected: OnyxEntry; diff --git a/tests/unit/ReceiptUploadRetryHandlerTest.ts b/tests/unit/ReceiptUploadRetryHandlerTest.ts index 57c788ec2aa1..a241ee0d75ab 100644 --- a/tests/unit/ReceiptUploadRetryHandlerTest.ts +++ b/tests/unit/ReceiptUploadRetryHandlerTest.ts @@ -75,6 +75,7 @@ function buildContext(transaction: Transaction, receiptErrorOverrides: Partial Date: Thu, 8 Oct 2026 15:46:31 +0100 Subject: [PATCH 349/781] fix: address commuter indicator CI failures --- src/selectors/Policy.ts | 3 --- tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts | 1 + .../home/TimeSensitiveSection/AddHomeAddressTest.tsx | 9 +++++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index b049967fa115..ef436cee18bd 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -451,8 +451,6 @@ const isActiveHomeAndOfficeCommuterExclusionPolicy = (policy: OnyxEntry) policy.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; -const hasHomeAndOfficeCommuterExclusionPolicySelector = (policies: OnyxCollection): boolean => Object.values(policies ?? {}).some(isActiveHomeAndOfficeCommuterExclusionPolicy); - const homeAndOfficeCommuterExclusionPolicyNameSelector = (policies: OnyxCollection): string | undefined => Object.values(policies ?? {}).find(isActiveHomeAndOfficeCommuterExclusionPolicy)?.name; @@ -605,7 +603,6 @@ export { hasReusablePoliciesConnectedToSelector, lastWorkspaceNumberSelector, hasOnlyPersonalPoliciesSelector, - hasHomeAndOfficeCommuterExclusionPolicySelector, homeAndOfficeCommuterExclusionPolicyNameSelector, policyAvatarFieldsSelector, policyNameSelector, diff --git a/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts b/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts index 932a5698f818..caf753d50e1f 100644 --- a/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts +++ b/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts @@ -17,6 +17,7 @@ const POLICY_ID = '1'; function makeHomeAndOfficePolicy(isOfficeWorkArrangement?: boolean, hasOfficeWorkArrangement?: boolean): Policy { return { ...createRandomPolicy(Number(POLICY_ID)), + areDistanceRatesEnabled: true, commuterExclusions: { method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, ...(isOfficeWorkArrangement === undefined ? {} : {isOfficeWorkArrangement}), diff --git a/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx index bf434d34568d..0426ae775ac1 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx @@ -6,8 +6,8 @@ import navigationRef from '@libs/Navigation/navigationRef'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import ONYXKEYS from '@src/ONYXKEYS'; +import HomeTaskGroup from '@src/pages/home/HomeTaskGroup'; import AddHomeAddress from '@src/pages/home/TimeSensitiveSection/items/AddHomeAddress'; -import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; @@ -95,7 +95,12 @@ jest.mock('@hooks/useThemeStyles', () => ); function TimeSensitiveSection() { - return ; + return ( + + ); } const renderTimeSensitiveSection = () => From 16c2c3473510d5a33264c7e09dc119219bf11760 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 8 Oct 2026 15:57:29 +0100 Subject: [PATCH 350/781] Revert "fix: address commuter indicator CI failures" This reverts commit 6cca7c335174cc5eb3384c071bb0cfa80a5e41f6. --- src/selectors/Policy.ts | 3 +++ tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts | 1 - .../home/TimeSensitiveSection/AddHomeAddressTest.tsx | 9 ++------- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index ef436cee18bd..b049967fa115 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -451,6 +451,8 @@ const isActiveHomeAndOfficeCommuterExclusionPolicy = (policy: OnyxEntry) policy.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +const hasHomeAndOfficeCommuterExclusionPolicySelector = (policies: OnyxCollection): boolean => Object.values(policies ?? {}).some(isActiveHomeAndOfficeCommuterExclusionPolicy); + const homeAndOfficeCommuterExclusionPolicyNameSelector = (policies: OnyxCollection): string | undefined => Object.values(policies ?? {}).find(isActiveHomeAndOfficeCommuterExclusionPolicy)?.name; @@ -603,6 +605,7 @@ export { hasReusablePoliciesConnectedToSelector, lastWorkspaceNumberSelector, hasOnlyPersonalPoliciesSelector, + hasHomeAndOfficeCommuterExclusionPolicySelector, homeAndOfficeCommuterExclusionPolicyNameSelector, policyAvatarFieldsSelector, policyNameSelector, diff --git a/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts b/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts index caf753d50e1f..932a5698f818 100644 --- a/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts +++ b/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts @@ -17,7 +17,6 @@ const POLICY_ID = '1'; function makeHomeAndOfficePolicy(isOfficeWorkArrangement?: boolean, hasOfficeWorkArrangement?: boolean): Policy { return { ...createRandomPolicy(Number(POLICY_ID)), - areDistanceRatesEnabled: true, commuterExclusions: { method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, ...(isOfficeWorkArrangement === undefined ? {} : {isOfficeWorkArrangement}), diff --git a/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx index 0426ae775ac1..bf434d34568d 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx @@ -6,8 +6,8 @@ import navigationRef from '@libs/Navigation/navigationRef'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import ONYXKEYS from '@src/ONYXKEYS'; -import HomeTaskGroup from '@src/pages/home/HomeTaskGroup'; import AddHomeAddress from '@src/pages/home/TimeSensitiveSection/items/AddHomeAddress'; +import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; @@ -95,12 +95,7 @@ jest.mock('@hooks/useThemeStyles', () => ); function TimeSensitiveSection() { - return ( - - ); + return ; } const renderTimeSensitiveSection = () => From ce8ba4641747b96fc5ff49aeef92acb88c367266 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 8 Oct 2026 16:08:55 +0100 Subject: [PATCH 351/781] chore: restore generated GitHub action bundles --- .../javascript/authorChecklist/index.js | 901 ++++++++++++----- .../javascript/awaitStagingDeploys/index.js | 901 ++++++++++++----- .../actions/javascript/bumpVersion/index.js | 901 ++++++++++++----- .../javascript/checkAndroidStatus/index.js | 903 +++++++++++++----- .../javascript/checkDeployBlockers/index.js | 901 ++++++++++++----- .../javascript/checkSVGCompression/index.js | 901 ++++++++++++----- .../javascript/failureNotifier/index.js | 901 ++++++++++++----- .../javascript/formatCodeCovComment/index.js | 901 ++++++++++++----- .../generateHelpPreviewComment/index.js | 901 ++++++++++++----- .../getAndroidRolloutPercentage/index.js | 903 +++++++++++++----- .../javascript/getArtifactInfo/index.js | 901 ++++++++++++----- .../getDeployPullRequestList/index.js | 901 ++++++++++++----- .../javascript/getPreviousVersion/index.js | 901 ++++++++++++----- .../javascript/getPullRequestDetails/index.js | 901 ++++++++++++----- .../getPullRequestIncrementalChanges/index.js | 901 ++++++++++++----- .../isAuthorizedContributor/index.js | 901 ++++++++++++----- .../isDeployChecklistLocked/index.js | 903 +++++++++++++----- .../markPullRequestsAsDeployed/index.js | 901 ++++++++++++----- .../javascript/postOrReplaceComment/index.js | 901 ++++++++++++----- .../javascript/proposalPoliceComment/index.js | 903 +++++++++++++----- .../reopenIssueWithComment/index.js | 901 ++++++++++++----- .../javascript/reviewerChecklist/index.js | 901 ++++++++++++----- .../validateReassureOutput/index.js | 901 ++++++++++++----- .../javascript/verifySignedCommits/index.js | 901 ++++++++++++----- .../verifyStagingBuildsSucceeded/index.js | 901 ++++++++++++----- 25 files changed, 16829 insertions(+), 5704 deletions(-) diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index fc3426566448..03420b1771b7 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; - } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index 906a5fe20da8..00e2f735fcb4 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js index 479e50a322da..c2258fb0a82d 100644 --- a/.github/actions/javascript/bumpVersion/index.js +++ b/.github/actions/javascript/bumpVersion/index.js @@ -2312,10 +2312,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -2382,7 +2438,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -2394,6 +2454,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request.host !== null) { @@ -2503,13 +2566,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -6116,6 +6187,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -6162,6 +6234,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -6324,24 +6399,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -6361,6 +6467,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6404,7 +6514,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -6429,9 +6539,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -6440,6 +6551,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6565,6 +6680,7 @@ var require_client_h1 = __commonJS({ } request.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6608,12 +6724,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6628,7 +6751,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6636,9 +6762,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6687,7 +6814,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request) { @@ -6705,6 +6832,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6717,6 +6861,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6747,8 +6914,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6769,11 +6944,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request.aborted || request.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request.aborted) { return; } - util.errorRequest(client, request, err || new RequestAbortedError()); + if (request.completed) { + if (request.upgrade || request.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } + return; + } + util.errorRequest(client, request, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -7129,6 +7311,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -7189,6 +7372,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -7352,16 +7539,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request.aborted || request.completed) { + const abort = (error2) => { + if (request.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request, err); + if (request.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -7376,18 +7569,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request.onUpgradeError(error2); + }; + const onReady = () => { + try { request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7948,9 +8165,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8456,8 +8674,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8628,7 +8846,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8649,6 +8866,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8848,7 +9066,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8858,6 +9075,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -9298,6 +9516,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -9351,6 +9587,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -9360,6 +9597,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -9421,7 +9669,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9470,9 +9720,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9480,6 +9742,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9487,6 +9750,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9506,6 +9774,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9533,7 +9802,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -16332,14 +16601,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -16422,7 +16725,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16552,18 +16859,14 @@ var require_parse2 = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -17385,7 +17688,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17491,31 +17794,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17534,20 +17832,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17557,19 +17847,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17600,8 +17888,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17609,18 +17903,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17635,6 +17932,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17694,6 +17998,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17714,6 +18021,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17727,6 +18037,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17737,32 +18050,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17805,6 +18132,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -18016,8 +18363,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -18061,9 +18406,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -18247,7 +18589,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -18322,19 +18670,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -18413,6 +18748,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -18432,10 +18801,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18466,63 +18838,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18531,17 +18870,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18557,43 +18895,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18618,12 +18955,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/checkAndroidStatus/index.js b/.github/actions/javascript/checkAndroidStatus/index.js index cf13138d08ce..de9e0e53ebc4 100644 --- a/.github/actions/javascript/checkAndroidStatus/index.js +++ b/.github/actions/javascript/checkAndroidStatus/index.js @@ -1875,10 +1875,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1945,7 +2001,11 @@ var require_request = __commonJS({ } else if (typeof val[i2] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i2]}`); + const str = `${val[i2]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1957,6 +2017,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2066,13 +2129,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5679,6 +5750,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5725,6 +5797,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5887,24 +5962,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5924,6 +6030,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5967,7 +6077,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5992,9 +6102,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -6003,6 +6114,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6128,6 +6243,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6171,12 +6287,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6191,7 +6314,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6199,9 +6325,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6250,7 +6377,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6268,6 +6395,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6280,6 +6424,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6310,8 +6477,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6332,11 +6507,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6692,6 +6874,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline: pipeline2 } = __require("node:stream"); var util = require_util(); var { @@ -6752,6 +6935,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6915,16 +7102,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6939,18 +7132,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7511,9 +7728,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8019,8 +8237,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8191,7 +8409,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8212,6 +8429,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8411,7 +8629,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8421,6 +8638,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8861,6 +9079,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8914,6 +9150,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8923,6 +9160,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8984,7 +9232,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9033,9 +9283,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9043,6 +9305,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9050,6 +9313,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9069,6 +9337,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9096,7 +9365,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15895,14 +16164,48 @@ var require_util6 = __commonJS({ for (let i2 = 0; i2 < path.length; ++i2) { const code = path.charCodeAt(i2); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i2 = 0; i2 < domain.length; ++i2) { + const code = domain.charCodeAt(i2); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i2 - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15985,7 +16288,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16115,18 +16422,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16948,7 +17251,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17054,31 +17357,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17097,20 +17395,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17120,19 +17410,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17163,8 +17451,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17172,18 +17466,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17198,6 +17495,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17257,6 +17561,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17277,6 +17584,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17290,6 +17600,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower2; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17300,32 +17613,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17368,6 +17695,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17579,8 +17926,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17624,9 +17969,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17810,7 +18152,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17885,19 +18233,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17976,6 +18311,40 @@ var require_eventsource_stream = __commonJS({ var CR2 = 13; var COLON2 = 58; var SPACE2 = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] < 48 || buffer[i2] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i2 = 0; i2 < length; i2++) { + if (line[i2] !== field[i2]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17995,10 +18364,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18029,63 +18401,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF2) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF2) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18094,17 +18433,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18120,43 +18458,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE2) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18181,12 +18518,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i2 = this.lineChunkIndex; i2 <= this.chunkIndex; i2++) { + const chunk = this.chunks[i2]; + const start = i2 === this.lineChunkIndex ? this.linePos : 0; + const end = i2 === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/checkDeployBlockers/index.js b/.github/actions/javascript/checkDeployBlockers/index.js index 2ca391c52c00..c59c854097c1 100644 --- a/.github/actions/javascript/checkDeployBlockers/index.js +++ b/.github/actions/javascript/checkDeployBlockers/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/checkSVGCompression/index.js b/.github/actions/javascript/checkSVGCompression/index.js index 1bd2d9dfd701..cad1560febba 100644 --- a/.github/actions/javascript/checkSVGCompression/index.js +++ b/.github/actions/javascript/checkSVGCompression/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/failureNotifier/index.js b/.github/actions/javascript/failureNotifier/index.js index 7eedc830062e..208e6286e656 100644 --- a/.github/actions/javascript/failureNotifier/index.js +++ b/.github/actions/javascript/failureNotifier/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/formatCodeCovComment/index.js b/.github/actions/javascript/formatCodeCovComment/index.js index 133b8de4b0d2..9aac21e43b56 100644 --- a/.github/actions/javascript/formatCodeCovComment/index.js +++ b/.github/actions/javascript/formatCodeCovComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/generateHelpPreviewComment/index.js b/.github/actions/javascript/generateHelpPreviewComment/index.js index 4fffa145a897..222ea227535c 100644 --- a/.github/actions/javascript/generateHelpPreviewComment/index.js +++ b/.github/actions/javascript/generateHelpPreviewComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error) { + util.errorRequest(client, request2, error); + util.destroy(socket, error); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error || new RequestAbortedError()); + } + return; + } + error = error || new RequestAbortedError(); + util.errorRequest(client, request2, error); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error); } - util.destroy(body, err); + util.destroy(body, error); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error) { + stream.off("response", onResponse); + abort(error); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error, data) => { - if (error) { - failWebsocketConnection(this.ws, error.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error, data) => { + if (error) { + const code = error instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getAndroidRolloutPercentage/index.js b/.github/actions/javascript/getAndroidRolloutPercentage/index.js index e970b3649819..1abd092e27ea 100644 --- a/.github/actions/javascript/getAndroidRolloutPercentage/index.js +++ b/.github/actions/javascript/getAndroidRolloutPercentage/index.js @@ -1875,10 +1875,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1945,7 +2001,11 @@ var require_request = __commonJS({ } else if (typeof val[i2] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i2]}`); + const str = `${val[i2]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1957,6 +2017,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request.host !== null) { @@ -2066,13 +2129,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5679,6 +5750,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5725,6 +5797,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5887,24 +5962,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5924,6 +6030,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -5967,7 +6077,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5992,9 +6102,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request.onUpgrade(statusCode, headers, socket, statusText); + } catch (error) { + util.errorRequest(client, request, error); + util.destroy(socket, error); } client[kResume](); } @@ -6003,6 +6114,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6128,6 +6243,7 @@ var require_client_h1 = __commonJS({ } request.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6171,12 +6287,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6191,7 +6314,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6199,9 +6325,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6250,7 +6377,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request) { @@ -6268,6 +6395,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6280,6 +6424,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6310,8 +6477,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6332,11 +6507,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request.aborted || request.completed) { + clearIdleSocketValidation(socket); + const abort = (error) => { + if (request.aborted) { + return; + } + if (request.completed) { + if (request.upgrade || request.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request, err || new RequestAbortedError()); + util.errorRequest(client, request, error || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6692,6 +6874,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline: pipeline2 } = __require("node:stream"); var util = require_util(); var { @@ -6752,6 +6935,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6915,16 +7102,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request.aborted || request.completed) { + const abort = (error) => { + if (request.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request, err); + if (request.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error || new RequestAbortedError()); + } + return; + } + error = error || new RequestAbortedError(); + util.errorRequest(client, request, error); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error); } - util.destroy(body, err); + util.destroy(body, error); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6939,18 +7132,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request.onUpgradeError(error); + }; + const onReady = () => { + try { request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error) { + stream.off("response", onResponse); + abort(error); + return; + } + if (request.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7511,9 +7728,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8019,8 +8237,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8191,7 +8409,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8212,6 +8429,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8411,7 +8629,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8421,6 +8638,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8861,6 +9079,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8914,6 +9150,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8923,6 +9160,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8984,7 +9232,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9033,9 +9283,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9043,6 +9305,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9050,6 +9313,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9069,6 +9337,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9096,7 +9365,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15895,14 +16164,48 @@ var require_util6 = __commonJS({ for (let i2 = 0; i2 < path.length; ++i2) { const code = path.charCodeAt(i2); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i2 = 0; i2 < domain.length; ++i2) { + const code = domain.charCodeAt(i2); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i2 - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15985,7 +16288,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16115,18 +16422,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; - } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16948,7 +17251,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17054,31 +17357,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17097,20 +17395,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17120,19 +17410,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17163,8 +17451,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17172,18 +17466,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17198,6 +17495,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17257,6 +17561,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17277,6 +17584,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17290,6 +17600,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower2; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17300,32 +17613,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error, data) => { - if (error) { - failWebsocketConnection(this.ws, error.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error, data) => { + if (error) { + const code = error instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17368,6 +17695,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17579,8 +17926,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17624,9 +17969,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17810,7 +18152,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17885,19 +18233,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17976,6 +18311,40 @@ var require_eventsource_stream = __commonJS({ var CR2 = 13; var COLON2 = 58; var SPACE2 = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] < 48 || buffer[i2] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i2 = 0; i2 < length; i2++) { + if (line[i2] !== field[i2]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17995,10 +18364,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18029,63 +18401,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF2) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF2) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18094,17 +18433,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18120,43 +18458,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE2) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18181,12 +18518,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i2 = this.lineChunkIndex; i2 <= this.chunkIndex; i2++) { + const chunk = this.chunks[i2]; + const start = i2 === this.lineChunkIndex ? this.linePos : 0; + const end = i2 === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getArtifactInfo/index.js b/.github/actions/javascript/getArtifactInfo/index.js index 1d7315a4ae65..b511ee174521 100644 --- a/.github/actions/javascript/getArtifactInfo/index.js +++ b/.github/actions/javascript/getArtifactInfo/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index 86ebf2b747d2..c6a083ec32cf 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getPreviousVersion/index.js b/.github/actions/javascript/getPreviousVersion/index.js index 0343158e5a02..dc48a205f79b 100644 --- a/.github/actions/javascript/getPreviousVersion/index.js +++ b/.github/actions/javascript/getPreviousVersion/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js index 78b36a004849..b7443787fc9b 100644 --- a/.github/actions/javascript/getPullRequestDetails/index.js +++ b/.github/actions/javascript/getPullRequestDetails/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js index 21c54003dde2..e868a069ae7e 100644 --- a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js +++ b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error3) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error3 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error3) { + util.errorRequest(client, request2, error3); + util.destroy(socket, error3); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error3) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error3 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error3) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error3 || new RequestAbortedError()); + } + return; + } + error3 = error3 || new RequestAbortedError(); + util.errorRequest(client, request2, error3); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error3); } - util.destroy(body, err); + util.destroy(body, error3); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error3) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error3); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error3) { + stream.off("response", onResponse); + abort(error3); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error3, data) => { - if (error3) { - failWebsocketConnection(this.ws, error3.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error3, data) => { + if (error3) { + const code = error3 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error3.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/isAuthorizedContributor/index.js b/.github/actions/javascript/isAuthorizedContributor/index.js index fea991ad887f..d43a19fced4c 100644 --- a/.github/actions/javascript/isAuthorizedContributor/index.js +++ b/.github/actions/javascript/isAuthorizedContributor/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index ebdf2bf6961c..9321db05e6d7 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -28447,10 +28447,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -28517,7 +28573,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -28529,6 +28589,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -28638,13 +28701,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -32251,6 +32322,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -32297,6 +32369,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -32459,24 +32534,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -32496,6 +32602,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -32539,7 +32649,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -32564,9 +32674,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -32575,6 +32686,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -32700,6 +32815,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -32743,12 +32859,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -32763,7 +32886,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -32771,9 +32897,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -32822,7 +32949,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -32840,6 +32967,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -32852,6 +32996,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -32882,8 +33049,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -32904,11 +33079,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -33264,6 +33446,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -33324,6 +33507,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -33487,16 +33674,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -33511,18 +33704,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -34083,9 +34300,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -34591,8 +34809,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -34763,7 +34981,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -34784,6 +35001,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -34983,7 +35201,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -34993,6 +35210,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -35433,6 +35651,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -35486,6 +35722,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -35495,6 +35732,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -35556,7 +35804,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -35605,9 +35855,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -35615,6 +35877,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -35622,6 +35885,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -35641,6 +35909,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -35668,7 +35937,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -42467,14 +42736,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -42557,7 +42860,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -42687,18 +42994,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -43520,7 +43823,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -43626,31 +43929,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -43669,20 +43967,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -43692,19 +43982,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -43735,8 +44023,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -43744,18 +44038,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -43770,6 +44067,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -43829,6 +44133,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -43849,6 +44156,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -43862,6 +44172,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -43872,32 +44185,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -43940,6 +44267,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -44151,8 +44498,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -44196,9 +44541,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -44382,7 +44724,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -44457,19 +44805,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -44548,6 +44883,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -44567,10 +44936,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -44601,63 +44973,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -44666,17 +45005,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -44692,43 +45030,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -44753,12 +45090,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 62a50ec22616..5eca45f374da 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/postOrReplaceComment/index.js b/.github/actions/javascript/postOrReplaceComment/index.js index fb9eb8df9e7b..195707dedc9f 100644 --- a/.github/actions/javascript/postOrReplaceComment/index.js +++ b/.github/actions/javascript/postOrReplaceComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 126b34462df4..beb367dd3ebd 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str2 = `${val[i]}`; + if (!isValidHeaderValue(str2)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str2); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message2 = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message2 = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message2, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message2 = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message2 = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message2, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/reopenIssueWithComment/index.js b/.github/actions/javascript/reopenIssueWithComment/index.js index f237f20694fd..2fe118ee3e64 100644 --- a/.github/actions/javascript/reopenIssueWithComment/index.js +++ b/.github/actions/javascript/reopenIssueWithComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/reviewerChecklist/index.js b/.github/actions/javascript/reviewerChecklist/index.js index a9419acf5a2e..8e220a2cf59e 100644 --- a/.github/actions/javascript/reviewerChecklist/index.js +++ b/.github/actions/javascript/reviewerChecklist/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/validateReassureOutput/index.js b/.github/actions/javascript/validateReassureOutput/index.js index b26522496b9c..e511f56ffd08 100644 --- a/.github/actions/javascript/validateReassureOutput/index.js +++ b/.github/actions/javascript/validateReassureOutput/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request.aborted || request.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request.aborted) { return; } - util.errorRequest(client, request, err || new RequestAbortedError()); + if (request.completed) { + if (request.upgrade || request.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } + return; + } + util.errorRequest(client, request, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request.aborted || request.completed) { + const abort = (error2) => { + if (request.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request, err); + if (request.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request.onUpgradeError(error2); + }; + const onReady = () => { + try { request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/verifySignedCommits/index.js b/.github/actions/javascript/verifySignedCommits/index.js index 087b5df387fd..d982728230d3 100644 --- a/.github/actions/javascript/verifySignedCommits/index.js +++ b/.github/actions/javascript/verifySignedCommits/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js b/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js index 1e9b71a5c3e0..d5508591a07b 100644 --- a/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js +++ b/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { From abc91a4c75e5b9a2bac96f63220ef8af38735277 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 8 Oct 2026 16:15:53 +0100 Subject: [PATCH 352/781] fix: address commuter CI failures --- src/selectors/Policy.ts | 3 --- tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts | 1 + .../home/TimeSensitiveSection/AddHomeAddressTest.tsx | 9 +++++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index b049967fa115..ef436cee18bd 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -451,8 +451,6 @@ const isActiveHomeAndOfficeCommuterExclusionPolicy = (policy: OnyxEntry) policy.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; -const hasHomeAndOfficeCommuterExclusionPolicySelector = (policies: OnyxCollection): boolean => Object.values(policies ?? {}).some(isActiveHomeAndOfficeCommuterExclusionPolicy); - const homeAndOfficeCommuterExclusionPolicyNameSelector = (policies: OnyxCollection): string | undefined => Object.values(policies ?? {}).find(isActiveHomeAndOfficeCommuterExclusionPolicy)?.name; @@ -605,7 +603,6 @@ export { hasReusablePoliciesConnectedToSelector, lastWorkspaceNumberSelector, hasOnlyPersonalPoliciesSelector, - hasHomeAndOfficeCommuterExclusionPolicySelector, homeAndOfficeCommuterExclusionPolicyNameSelector, policyAvatarFieldsSelector, policyNameSelector, diff --git a/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts b/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts index 932a5698f818..caf753d50e1f 100644 --- a/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts +++ b/tests/unit/hooks/useTimeSensitiveHomeAddress.test.ts @@ -17,6 +17,7 @@ const POLICY_ID = '1'; function makeHomeAndOfficePolicy(isOfficeWorkArrangement?: boolean, hasOfficeWorkArrangement?: boolean): Policy { return { ...createRandomPolicy(Number(POLICY_ID)), + areDistanceRatesEnabled: true, commuterExclusions: { method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, ...(isOfficeWorkArrangement === undefined ? {} : {isOfficeWorkArrangement}), diff --git a/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx index bf434d34568d..0426ae775ac1 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/AddHomeAddressTest.tsx @@ -6,8 +6,8 @@ import navigationRef from '@libs/Navigation/navigationRef'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import ONYXKEYS from '@src/ONYXKEYS'; +import HomeTaskGroup from '@src/pages/home/HomeTaskGroup'; import AddHomeAddress from '@src/pages/home/TimeSensitiveSection/items/AddHomeAddress'; -import TimeSensitiveGroup from '@src/pages/home/TimeSensitiveSection/TimeSensitiveGroup'; import useTimeSensitiveItems from '@src/pages/home/TimeSensitiveSection/useTimeSensitiveItems'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; @@ -95,7 +95,12 @@ jest.mock('@hooks/useThemeStyles', () => ); function TimeSensitiveSection() { - return ; + return ( + + ); } const renderTimeSensitiveSection = () => From 3ed341ba566fff042ee0ca9edd5f773fc5567571 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:39:39 +0100 Subject: [PATCH 353/781] make EditAgentPage dynamic --- config/eslint/eslint.seatbelt.tsv | 2 +- src/ROUTES.ts | 9 +++--- src/SCREENS.ts | 2 +- src/hooks/useAgents.ts | 5 ++-- .../ModalStackNavigators/index.tsx | 2 +- .../RELATIONS/SETTINGS_TO_RHP.ts | 2 +- src/libs/Navigation/linkingConfig/config.ts | 4 +-- src/libs/Navigation/types.ts | 2 +- .../replaceOptimisticAgentWithActualAgent.ts | 7 ++++- ...AgentPage.tsx => DynamicEditAgentPage.tsx} | 15 +++++----- .../Agents/Fields/EditAgentAvatarPage.tsx | 5 ++-- .../settings/Agents/Fields/EditNamePage.tsx | 7 +++-- ...placeOptimisticAgentWithActualAgentTest.ts | 4 +-- .../unit/pages/settings/EditAgentPageTest.tsx | 28 +++++++++---------- 14 files changed, 52 insertions(+), 42 deletions(-) rename src/pages/settings/Agents/{EditAgentPage.tsx => DynamicEditAgentPage.tsx} (93%) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index a5ed0909ebaf..e31e328848c9 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1191,7 +1191,7 @@ "../../src/pages/settings/AboutPage/AboutPage.tsx" "react-hooks/refs" 1 "../../src/pages/settings/Agents/AddAgentPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/AgentsPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/EditAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/DynamicEditAgentPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/Fields/EditNamePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 61b640952260..2ba41fa89bbe 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2091,6 +2091,11 @@ const DYNAMIC_ROUTES = { }, queryParams: ['action', 'iouType', 'transactionID', 'reportID', 'upgradeBackTo', 'shouldSubmitExpense', 'upgradePath'], }, + AGENT_EDIT: { + path: ':accountID/edit', + getRoute: (accountID: number) => `${accountID}/edit` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, } as const satisfies DynamicRoutes; const ROUTES = { @@ -2626,10 +2631,6 @@ const ROUTES = { }, }, SETTINGS_AGENTS_ADD_AVATAR: 'settings/agents/new/custom/avatar', - SETTINGS_AGENTS_EDIT: { - route: 'settings/agents/:accountID/edit', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit` as const, - }, SETTINGS_AGENTS_EDIT_NAME: { route: 'settings/agents/:accountID/edit/name', getRoute: (accountID: number) => `settings/agents/${accountID}/edit/name` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 5aaa0493fc58..cf488c6da99b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -143,7 +143,7 @@ const SCREENS = { NEW: 'Settings_Agents_New', ADD: 'Settings_Agents_Add', ADD_AVATAR: 'Settings_Agents_Add_Avatar', - EDIT: 'Settings_Agents_Edit', + DYNAMIC_EDIT: 'Dynamic_Settings_Agents_Edit', EDIT_NAME: 'Settings_Agents_Edit_Name', EDIT_PROMPT: 'Settings_Agents_Edit_Prompt', EDIT_AVATAR: 'Settings_Agents_Edit_Avatar', diff --git a/src/hooks/useAgents.ts b/src/hooks/useAgents.ts index 018cc2d772f8..e8455a5698f6 100644 --- a/src/hooks/useAgents.ts +++ b/src/hooks/useAgents.ts @@ -6,11 +6,12 @@ import {AgentRowData, AgentsTableColumnKey} from '@components/Tables/AgentsTable import {clearAgentDeleteError, clearAgentError, clearAgentUpdateError, deleteAgent, openAgentsPage} from '@libs/actions/Agent'; import {getRuleBotEnforcedPolicy} from '@libs/AgentRulesUtils'; import {getLatestError} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import {PendingAction} from '@src/types/onyx/OnyxCommon'; import {useEffect, useRef, useState} from 'react'; @@ -82,7 +83,7 @@ function useAgents() { pendingAction, errors: Object.keys(rowErrors).length > 0 ? rowErrors : undefined, disabled: isPendingDeletion, - action: () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)), + action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(accountID))), onChatPress: () => chatWithAgent(accountID), onCopilotPress: () => switchToDelegator(details.login ?? ''), dismissError: () => handleErrorClose(pendingAction, accountID), diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 0206f5abda22..bd30307184a0 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -510,7 +510,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Agents/NewAgentPage').default), [SCREENS.SETTINGS.AGENTS.ADD]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/AddAgentPage').default), [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/AddAgentAvatarPage').default), - [SCREENS.SETTINGS.AGENTS.EDIT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/EditAgentPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicEditAgentPage').default), [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditNamePage').default), [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditPromptPage').default), [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditAgentAvatarPage').default), diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index e8091df20477..f0e604cf71bf 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -75,7 +75,7 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_AGENTS_ADD_AVATAR, exact: true, }, - [SCREENS.SETTINGS.AGENTS.EDIT]: { - path: ROUTES.SETTINGS_AGENTS_EDIT.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: { + path: DYNAMIC_ROUTES.AGENT_EDIT.path, parse: { accountID: Number, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ea5a2ed9e9be..2f7fd1992110 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -288,7 +288,7 @@ type SettingsNavigatorParamList = { policyID?: string; }; [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: undefined; - [SCREENS.SETTINGS.AGENTS.EDIT]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: { accountID: number; }; [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: { diff --git a/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts index bcb75c6b7944..3feabfd7a85a 100644 --- a/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts +++ b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts @@ -27,7 +27,12 @@ import Onyx from 'react-native-onyx'; * rewrites the queued requests themselves. */ -const AGENT_SETTINGS_SCREENS = new Set([SCREENS.SETTINGS.AGENTS.EDIT, SCREENS.SETTINGS.AGENTS.EDIT_NAME, SCREENS.SETTINGS.AGENTS.EDIT_PROMPT, SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]); +const AGENT_SETTINGS_SCREENS = new Set([ + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT, + SCREENS.SETTINGS.AGENTS.EDIT_NAME, + SCREENS.SETTINGS.AGENTS.EDIT_PROMPT, + SCREENS.SETTINGS.AGENTS.EDIT_AVATAR, +]); // Reports are only read inside the mapping callback, so connectWithoutView() is used. On app start the mapping can // arrive before this collection is hydrated, so consumers wait for this promise. Onyx always fires the callback at diff --git a/src/pages/settings/Agents/EditAgentPage.tsx b/src/pages/settings/Agents/DynamicEditAgentPage.tsx similarity index 93% rename from src/pages/settings/Agents/EditAgentPage.tsx rename to src/pages/settings/Agents/DynamicEditAgentPage.tsx index 1a950b1497a2..40cd359a18ff 100644 --- a/src/pages/settings/Agents/EditAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicEditAgentPage.tsx @@ -11,6 +11,7 @@ import ScrollView from '@components/ScrollView'; import useChatWithAgent from '@hooks/useChatWithAgent'; import useConfirmModal from '@hooks/useConfirmModal'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -31,16 +32,16 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {Str} from 'expensify-common'; import React from 'react'; import {View} from 'react-native'; -type EditAgentPageProps = PlatformStackScreenProps; +type DynamicEditAgentPageProps = PlatformStackScreenProps; -function EditAgentPage({route}: EditAgentPageProps) { +function DynamicEditAgentPage({route}: DynamicEditAgentPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Trashcan', 'ChatBubble', 'MagnifyingGlass', 'Users']); @@ -57,7 +58,7 @@ function EditAgentPage({route}: EditAgentPageProps) { const shouldShowNotFoundPage = isLoadingApp === false && isOnyxLoaded && !agent && !personalDetails; const agentLogin = personalDetails?.login ?? ''; - const handleBackPress = () => Navigation.goBack(); + const handleBackPress = () => Navigation.goBack(useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT.path)); const handleEditAvatarPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.getRoute(accountID)); const handleEditNamePress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_NAME.getRoute(accountID)); const handleEditPromptPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.getRoute(accountID)); @@ -111,7 +112,7 @@ function EditAgentPage({route}: EditAgentPageProps) { return ( @@ -187,6 +188,6 @@ function EditAgentPage({route}: EditAgentPageProps) { ); } -EditAgentPage.displayName = 'EditAgentPage'; +DynamicEditAgentPage.displayName = 'DynamicEditAgentPage'; -export default EditAgentPage; +export default DynamicEditAgentPage; diff --git a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx index 14443666d669..09e38f4a5146 100644 --- a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx @@ -21,6 +21,7 @@ import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog'; import type {AgentAvatarID} from '@libs/Avatars/AgentAvatarCatalog'; import {validateAvatarImage} from '@libs/AvatarUtils'; import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -30,7 +31,7 @@ import {updateAgentAvatar} from '@userActions/Agent'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -236,7 +237,7 @@ function EditAgentAvatarPage({route}: EditAgentAvatarPageProps) { return ( ); } diff --git a/src/pages/settings/Agents/Fields/EditNamePage.tsx b/src/pages/settings/Agents/Fields/EditNamePage.tsx index 092d13abb51f..83b5347a2feb 100644 --- a/src/pages/settings/Agents/Fields/EditNamePage.tsx +++ b/src/pages/settings/Agents/Fields/EditNamePage.tsx @@ -11,13 +11,14 @@ import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateAgentName} from '@libs/actions/Agent'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/EditAgentNameForm'; @@ -35,7 +36,7 @@ function EditNamePage({route}: EditNamePageProps) { const handleSubmit = (values: FormOnyxValues) => { updateAgentName(accountID, values[INPUT_IDS.FIRST_NAME].trim(), personalDetails?.displayName ?? ''); - Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)); + Navigation.goBack(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(accountID))); }; return ( @@ -47,7 +48,7 @@ function EditNamePage({route}: EditNamePageProps) { > Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID))} + onBackButtonPress={() => Navigation.goBack(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(accountID)))} /> { key: 'agent-settings-stack-key', index: 1, routes: [ - {key: 'agents-edit-route-key', name: SCREENS.SETTINGS.AGENTS.EDIT, params: {accountID: String(optimisticAccountID)}}, + {key: 'agents-edit-route-key', name: SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT, params: {accountID: String(optimisticAccountID)}}, {key: 'agents-edit-name-route-key', name: SCREENS.SETTINGS.AGENTS.EDIT_NAME, params: {accountID: String(optimisticAccountID)}}, ], }, diff --git a/tests/unit/pages/settings/EditAgentPageTest.tsx b/tests/unit/pages/settings/EditAgentPageTest.tsx index 3d7bde35c08a..c47f56fefb0f 100644 --- a/tests/unit/pages/settings/EditAgentPageTest.tsx +++ b/tests/unit/pages/settings/EditAgentPageTest.tsx @@ -8,7 +8,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; -import EditAgentPage from '@pages/settings/Agents/EditAgentPage'; +import DynamicEditAgentPage from '@pages/settings/Agents/DynamicEditAgentPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -148,13 +148,13 @@ const mockUseOnyx = jest.mocked(useOnyx); const TEST_ACCOUNT_ID = 12345; -type EditAgentPageRoute = PlatformStackScreenProps['route']; -type EditAgentPageNavigation = PlatformStackScreenProps['navigation']; +type DynamicEditAgentPageRoute = PlatformStackScreenProps['route']; +type DynamicEditAgentPageNavigation = PlatformStackScreenProps['navigation']; -const mockRoute = createMock({params: {accountID: TEST_ACCOUNT_ID}}); -const mockNavigation = createMock({}); +const mockRoute = createMock({params: {accountID: TEST_ACCOUNT_ID}}); +const mockNavigation = createMock({}); -describe('EditAgentPage', () => { +describe('DynamicEditAgentPage', () => { beforeEach(() => { jest.clearAllMocks(); mockUseOnyx.mockImplementation((key, options) => { @@ -177,7 +177,7 @@ describe('EditAgentPage', () => { }); const {toJSON} = render( - , @@ -195,7 +195,7 @@ describe('EditAgentPage', () => { }); const {toJSON} = render( - , @@ -206,7 +206,7 @@ describe('EditAgentPage', () => { it('renders delete agent menu item', () => { const {toJSON} = render( - , @@ -224,7 +224,7 @@ describe('EditAgentPage', () => { }); render( - , @@ -248,7 +248,7 @@ describe('EditAgentPage', () => { }); const {toJSON} = render( - , @@ -266,7 +266,7 @@ describe('EditAgentPage', () => { }); const {toJSON} = render( - , @@ -284,7 +284,7 @@ describe('EditAgentPage', () => { }); const {toJSON} = render( - , @@ -299,7 +299,7 @@ describe('EditAgentPage', () => { mockUseOnyx.mockReturnValue([undefined, {status: 'loading'}]); const {toJSON} = render( - , From d487f47d6ebcdfe463ceef92b64e665dcfcb310a Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:47:33 +0100 Subject: [PATCH 354/781] skipMatchingFullScreenRoute on opening agents pages --- src/hooks/useAgents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useAgents.ts b/src/hooks/useAgents.ts index e8455a5698f6..79a8f254c51f 100644 --- a/src/hooks/useAgents.ts +++ b/src/hooks/useAgents.ts @@ -83,7 +83,7 @@ function useAgents() { pendingAction, errors: Object.keys(rowErrors).length > 0 ? rowErrors : undefined, disabled: isPendingDeletion, - action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(accountID))), + action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_EDIT.getRoute(accountID)), {skipMatchingFullScreenRoute: true}), onChatPress: () => chatWithAgent(accountID), onCopilotPress: () => switchToDelegator(details.login ?? ''), dismissError: () => handleErrorClose(pendingAction, accountID), From c23789066a1e161e798ee0e3b5528b0223b8b08e Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Thu, 8 Oct 2026 18:02:18 +0200 Subject: [PATCH 355/781] test: add unit test for track expense try again logic --- .../buildTrackExpenseRetryPayload.ts | 4 - tests/actions/IOUTest/TrackExpenseTest.ts | 39 ++++++++++ tests/unit/ReceiptUploadRetryHandlerTest.ts | 73 ++++++++++++++++++- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts b/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts index f8391071c563..45a27b5c10b0 100644 --- a/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts +++ b/src/libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload.ts @@ -44,7 +44,6 @@ function buildTrackExpenseRetryPayload(context: ReceiptRetryContext, receiptFile } const currentUser = getCurrentUserPersonalDetails(); - // Unreported amounts are stored negated; this returns them positive, as `trackExpense` expects. const details = getTransactionDetails(transaction); if (!details) { return undefined; @@ -78,9 +77,7 @@ function buildTrackExpenseRetryPayload(context: ReceiptRetryContext, receiptFile isFromGlobalCreate: getIsFromGlobalCreate(transaction), receipt, }, - // Same ID, so if the first attempt reached Auth the retry gets `Transaction already created.` instead of a second expense. optimisticTransactionID: transaction.transactionID, - // Without these two, the retry creates a second IOU action and thread for the same transaction. currentReportActionID: iouActionID, existingTransactionThreadReportID: transactionThreadReportID, existingTransaction: transaction, @@ -94,7 +91,6 @@ function buildTrackExpenseRetryPayload(context: ReceiptRetryContext, receiptFile quickAction: undefined, recentWaypoints: [], isSelfTourViewed, - // Only read when a draft chat report builds a new workspace, which the create path behind this error never does. currentUserLocalCurrency: undefined, delegateAccountID, reportActionsList: undefined, diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index 2f622f819bf2..333647aed922 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -1385,6 +1385,45 @@ describe('actions/IOU/TrackExpense', () => { expect(actionableWhisper).toBeTruthy(); }); + it('reuses the failed IOU action and thread on a receipt retry, and clears the thread creation error', async () => { + // Given a self-DM and the thread a failed track left behind, still carrying its creation error + mockFetch?.pause?.(); + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-retry', + }; + const failedIOUActionID = '1111111111111111'; + const failedThreadReportID = '2222222222222222'; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${failedThreadReportID}`, { + reportID: failedThreadReportID, + parentReportID: selfDMReport.reportID, + parentReportActionID: failedIOUActionID, + errorFields: {createChat: {error: 'report.genericCreateReportFailureMessage'}}, + }); + + // When the receipt retry calls trackExpense with the failed attempt's IDs + trackExpense({ + ...getDefaultTrackExpenseParams(selfDMReport, {amount: 1200}), + optimisticTransactionID: '3333333333333333', + currentReportActionID: failedIOUActionID, + existingTransactionThreadReportID: failedThreadReportID, + isRetry: true, + }); + await waitForBatchedUpdates(); + + // Then the self-DM holds one IOU action under the failed ID, linked to the same thread, so no second expense or thread appears + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); + const iouActions = Object.values(reportActions ?? {}).filter((action) => isMoneyRequestAction(action)); + expect(iouActions).toHaveLength(1); + expect(iouActions.at(0)?.reportActionID).toBe(failedIOUActionID); + expect(iouActions.at(0)?.childReportID).toBe(failedThreadReportID); + + // And the thread no longer shows the creation error from the failed attempt + const threadReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${failedThreadReportID}`); + expect(threadReport?.errorFields?.createChat).toBeFalsy(); + }); + it('should set correct tax fields when tax parameters are provided', async () => { // Given a selfDM report and transaction with tax const selfDMReport: Report = { diff --git a/tests/unit/ReceiptUploadRetryHandlerTest.ts b/tests/unit/ReceiptUploadRetryHandlerTest.ts index a241ee0d75ab..5a0d22abb861 100644 --- a/tests/unit/ReceiptUploadRetryHandlerTest.ts +++ b/tests/unit/ReceiptUploadRetryHandlerTest.ts @@ -1,12 +1,13 @@ import retryReceiptUpload, {canRetryReceiptUpload} from '@libs/ReceiptUploadRetryHandler'; import buildReplaceReceiptRetryPayload from '@libs/ReceiptUploadRetryHandler/buildReplaceReceiptRetryPayload'; import buildRetryPayload, {canBuildRetryPayload} from '@libs/ReceiptUploadRetryHandler/buildRetryPayload'; +import buildTrackExpenseRetryPayload from '@libs/ReceiptUploadRetryHandler/buildTrackExpenseRetryPayload'; import resolveReceiptFile from '@libs/ReceiptUploadRetryHandler/resolveReceiptFile'; import type {ReceiptRetryContext} from '@libs/ReceiptUploadRetryHandler/types'; import {replaceReceipt} from '@userActions/IOU/Receipt'; import type {ReplaceReceiptRetryParams} from '@userActions/IOU/Receipt'; -import {requestMoney} from '@userActions/IOU/TrackExpense'; +import {requestMoney, trackExpense} from '@userActions/IOU/TrackExpense'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -21,7 +22,7 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@libs/ReceiptUploadRetryHandler/resolveReceiptFile', () => ({__esModule: true, default: jest.fn()})); jest.mock('@userActions/IOU/Receipt', () => ({...jest.requireActual>('@userActions/IOU/Receipt'), replaceReceipt: jest.fn()})); -jest.mock('@userActions/IOU/TrackExpense', () => ({...jest.requireActual>('@userActions/IOU/TrackExpense'), requestMoney: jest.fn()})); +jest.mock('@userActions/IOU/TrackExpense', () => ({...jest.requireActual>('@userActions/IOU/TrackExpense'), requestMoney: jest.fn(), trackExpense: jest.fn()})); const CURRENT_USER_ACCOUNT_ID = 1; const TRANSACTION_ID = '7000000000000001'; @@ -30,6 +31,7 @@ const CHAT_REPORT_ID = '9000000000000001'; const POLICY_ID = 'A0000000000000001'; const IOU_ACTION_ID = '6000000000000001'; const THREAD_REPORT_ID = '5000000000000001'; +const SELF_DM_REPORT_ID = '4000000000000001'; const receiptFile: FileObject = {name: 'receipt.jpg', type: 'image/jpeg', uri: 'file:///receipts/receipt.jpg'}; @@ -92,6 +94,12 @@ function buildReplaceReceiptContext(retryParamsOverrides: Partial { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -133,7 +141,7 @@ describe('buildRetryPayload', () => { expect(canBuildRetryPayload(buildContext(buildFailedTransaction(), {action: CONST.IOU.ACTION_PARAMS.REPLACE_RECEIPT}))).toBe(false); }); - it('offers no retry for a trackExpense failure, whose convert-and-submit path the handler does not replay', () => { + it('does not rebuild a trackExpense failure as a RequestMoney call, because track has its own builder', () => { expect(canBuildRetryPayload(buildContext(buildFailedTransaction(), {action: CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE}))).toBe(false); }); @@ -235,4 +243,63 @@ describe('buildRetryPayload', () => { expect(jest.mocked(replaceReceipt).mock.invocationCallOrder.at(0)).toBeLessThan(clearReceiptError.mock.invocationCallOrder.at(0) ?? 0); }); }); + describe('trackExpense retry', () => { + beforeEach(() => { + jest.mocked(resolveReceiptFile).mockResolvedValue(receiptFile); + jest.mocked(trackExpense).mockReset(); + jest.mocked(requestMoney).mockReset(); + }); + + it('offers a retry for a failed track in the self-DM', () => { + // Given a scan tracked in the self-DM whose upload failed + const context = buildTrackExpenseContext(); + + // When the receipt view asks whether it can retry + const canRetry = canRetryReceiptUpload(context); + + // Then Try again is offered + expect(canRetry).toBe(true); + }); + + it('offers no retry for a track outside the self-DM, because a retry there would also have to leave the report totals alone', () => { + // Given a track failure whose expense sits on a workspace expense report + const context = buildTrackExpenseContext({reportID: IOU_REPORT_ID, chatReportID: CHAT_REPORT_ID, policyID: POLICY_ID, type: CONST.REPORT.TYPE.EXPENSE} as Report); + + // When the receipt view asks whether it can retry + const canRetry = canRetryReceiptUpload(context); + + // Then only Save is offered + expect(canRetry).toBe(false); + }); + + it('reuses the failed transaction, IOU action and thread, so the retry does not create a second tracked expense', () => { + // Given a failed track in the self-DM + const context = buildTrackExpenseContext(); + + // When the retry payload is rebuilt + const payload = buildTrackExpenseRetryPayload(context, receiptFile); + + // Then it points at the records the failed attempt created, and passes the amount positive as trackExpense expects + expect(payload?.optimisticTransactionID).toBe(TRANSACTION_ID); + expect(payload?.currentReportActionID).toBe(IOU_ACTION_ID); + expect(payload?.existingTransactionThreadReportID).toBe(THREAD_REPORT_ID); + expect(payload?.report?.reportID).toBe(SELF_DM_REPORT_ID); + expect(payload?.transactionParams.amount).toBe(1200); + }); + + it('dispatches trackExpense as a retry and clears the error after it', async () => { + // Given a failed track in the self-DM + const clearReceiptError = jest.fn(() => Promise.resolve()); + + // When Try again is pressed + const outcome = await retryReceiptUpload(buildTrackExpenseContext(), clearReceiptError); + + // Then trackExpense is sent once as a silent retry, not requestMoney, and the error is cleared only after it + expect(outcome).toBe('dispatched'); + expect(jest.mocked(trackExpense)).toHaveBeenCalledTimes(1); + expect(jest.mocked(trackExpense)).toHaveBeenCalledWith(expect.objectContaining({isRetry: true, shouldPlaySound: false})); + expect(jest.mocked(requestMoney)).not.toHaveBeenCalled(); + expect(jest.mocked(trackExpense).mock.invocationCallOrder.at(0)).toBeLessThan(clearReceiptError.mock.invocationCallOrder.at(0) ?? 0); + }); + }); }); From 01e610a09fcf8db3619c0ce4af35add1afb9baf7 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 8 Oct 2026 18:11:28 +0200 Subject: [PATCH 356/781] [Insights polish] Only offer Group by options on Spend over time that fit the Date range --- .../Insights/charts/InsightsChartWidget.tsx | 17 ++-- .../controls/InsightsGroupByDropdown.tsx | 15 ++- src/pages/Insights/insightsGroupByOptions.ts | 62 +++++++++++++ src/pages/Insights/useInsightsFilters.ts | 9 +- tests/ui/InsightsControlsTest.tsx | 44 +++++++++ tests/unit/hooks/useInsightsFilters.test.ts | 88 +++++++++++++++++- tests/unit/insightsGroupByOptionsTest.ts | 93 +++++++++++++++++++ 7 files changed, 312 insertions(+), 16 deletions(-) create mode 100644 src/pages/Insights/insightsGroupByOptions.ts create mode 100644 tests/unit/insightsGroupByOptionsTest.ts diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx index aa881f1acf81..acb1d56ab332 100644 --- a/src/pages/Insights/charts/InsightsChartWidget.tsx +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -20,6 +20,7 @@ import {INSIGHTS_CHART_STATE, resolveInsightsChartData} from '@libs/resolveInsig import InsightsGroupByDropdown from '@pages/Insights/controls/InsightsGroupByDropdown'; import type {InsightsChartSpec} from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import {getInsightsGroupByOptions} from '@pages/Insights/insightsGroupByOptions'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -64,12 +65,16 @@ function InsightsChartWidget({chart, queryJSON, snapshot, filters, onRetry, onGr const isLoading = state === INSIGHTS_CHART_STATE.LOADING; const shouldShowTable = chart.view === CONST.SEARCH.VIEW.BAR || chart.view === CONST.SEARCH.VIEW.PIE; - const groupByControl = onGroupByChange ? ( - - ) : null; + const groupByOptions = getInsightsGroupByOptions(filters.date); + + const groupByControl = + onGroupByChange && groupByOptions.length > 0 ? ( + + ) : null; const headerMenu = !!queryJSON && (state === INSIGHTS_CHART_STATE.READY || isLoading) ? ( diff --git a/src/pages/Insights/controls/InsightsGroupByDropdown.tsx b/src/pages/Insights/controls/InsightsGroupByDropdown.tsx index cdab886e72b7..47d58bfa7eb3 100644 --- a/src/pages/Insights/controls/InsightsGroupByDropdown.tsx +++ b/src/pages/Insights/controls/InsightsGroupByDropdown.tsx @@ -7,7 +7,8 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; -import DEFAULT_INSIGHTS_FILTERS, {INSIGHTS_GROUP_BY_OPTIONS} from '@pages/Insights/insightsFilters'; +import DEFAULT_INSIGHTS_FILTERS from '@pages/Insights/insightsFilters'; +import {getFittingGroupBy} from '@pages/Insights/insightsGroupByOptions'; import variables from '@styles/variables'; @@ -21,14 +22,18 @@ type InsightsGroupByDropdownProps = { /** Time bucket the headline chart aggregates into */ groupBy: InsightsFilters['groupBy']; + /** Options that fit the date range */ + options: Array; + onChange: (groupBy: InsightsFilters['groupBy']) => void; }; -function InsightsGroupByDropdown({groupBy, onChange}: InsightsGroupByDropdownProps) { +function InsightsGroupByDropdown({groupBy, options, onChange}: InsightsGroupByDropdownProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const defaultGroupBy = getFittingGroupBy(DEFAULT_INSIGHTS_FILTERS.groupBy, options); - const items: Array> = INSIGHTS_GROUP_BY_OPTIONS.map((option) => ({ + const items: Array> = options.map((option) => ({ text: translate(`search.filters.groupBy.${option}`), value: option, })); @@ -41,9 +46,9 @@ function InsightsGroupByDropdown({groupBy, onChange}: InsightsGroupByDropdownPro label={label} items={items} value={selectedItem} - defaultValue={DEFAULT_INSIGHTS_FILTERS.groupBy} + defaultValue={defaultGroupBy} closeOverlay={closeOverlay} - onChange={(item) => onChange(item?.value ?? DEFAULT_INSIGHTS_FILTERS.groupBy)} + onChange={(item) => onChange(item?.value ?? defaultGroupBy)} /> ); diff --git a/src/pages/Insights/insightsGroupByOptions.ts b/src/pages/Insights/insightsGroupByOptions.ts new file mode 100644 index 000000000000..cae9d72af9a8 --- /dev/null +++ b/src/pages/Insights/insightsGroupByOptions.ts @@ -0,0 +1,62 @@ +import {getDateRangeForPreset} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +import {addDays, differenceInCalendarDays, differenceInMonths, parseISO} from 'date-fns'; + +import type {InsightsFilters} from './insightsFilters'; + +import {INSIGHTS_GROUP_BY_OPTIONS} from './insightsFilters'; + +type InsightsGroupBy = InsightsFilters['groupBy']; + +/** Longest range, in days, still readable one point per day */ +const MAX_DAYS_GROUPED_BY_DAY = 62; + +const MIN_DAYS_GROUPED_BY_WEEK = 14; +const MIN_MONTHS_BY_GROUP_BY = { + [CONST.SEARCH.GROUP_BY.MONTH]: 2, + [CONST.SEARCH.GROUP_BY.QUARTER]: 6, + [CONST.SEARCH.GROUP_BY.YEAR]: 24, +} as const; + +/** Returns the Group by options that fit the date range, in the dropdown's order */ +function getInsightsGroupByOptions(date: InsightsFilters['date']): InsightsGroupBy[] { + // Custom date -> no group by + if ('on' in date) { + return []; + } + + const boundaries = 'from' in date ? {start: date.from, end: date.to} : getDateRangeForPreset(date.preset); + if (!boundaries.start || !boundaries.end) { + return []; + } + + const start = parseISO(boundaries.start); + const end = parseISO(boundaries.end); + const days = differenceInCalendarDays(end, start) + 1; + const months = differenceInMonths(addDays(end, 1), start); + + return INSIGHTS_GROUP_BY_OPTIONS.filter((option) => { + switch (option) { + case CONST.SEARCH.GROUP_BY.DAY: + return days <= MAX_DAYS_GROUPED_BY_DAY; + case CONST.SEARCH.GROUP_BY.WEEK: + return days >= MIN_DAYS_GROUPED_BY_WEEK; + default: + return months >= MIN_MONTHS_BY_GROUP_BY[option]; + } + }); +} + +/** Keeps the Group by when the options include it, otherwise picks the nearest option they do include. */ +function getFittingGroupBy(groupBy: InsightsGroupBy, options: InsightsGroupBy[]): InsightsGroupBy { + if (options.length === 0 || options.includes(groupBy)) { + return groupBy; + } + + const distanceFromSelection = (option: InsightsGroupBy) => Math.abs(INSIGHTS_GROUP_BY_OPTIONS.indexOf(option) - INSIGHTS_GROUP_BY_OPTIONS.indexOf(groupBy)); + return options.reduce((nearest, option) => (distanceFromSelection(option) < distanceFromSelection(nearest) ? option : nearest)); +} + +export {getFittingGroupBy, getInsightsGroupByOptions}; diff --git a/src/pages/Insights/useInsightsFilters.ts b/src/pages/Insights/useInsightsFilters.ts index 78498aa3f8a0..a681d276ee9e 100644 --- a/src/pages/Insights/useInsightsFilters.ts +++ b/src/pages/Insights/useInsightsFilters.ts @@ -13,6 +13,7 @@ import type {InsightsFilters} from './insightsFilters'; import INSIGHTS_DASHBOARD_SPECS from './dashboardSpecs'; import {parseInsightsFilters} from './insightsFilterParsing'; import DEFAULT_INSIGHTS_FILTERS from './insightsFilters'; +import {getFittingGroupBy, getInsightsGroupByOptions} from './insightsGroupByOptions'; import {buildInsightsQueryString} from './insightsQueries'; type UseInsightsFiltersResult = { @@ -29,6 +30,10 @@ type UseInsightsFiltersResult = { setFilters: (update: Partial) => void; }; +function withFittingGroupBy(filters: InsightsFilters): InsightsFilters { + return {...filters, groupBy: getFittingGroupBy(filters.groupBy, getInsightsGroupByOptions(filters.date))}; +} + function useInsightsFilters(dashboard: InsightsDashboardID): UseInsightsFiltersResult { const {searchKey} = INSIGHTS_DASHBOARD_SPECS[dashboard]; const [activePolicyID, activePolicyIDMetadata] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); @@ -40,10 +45,10 @@ function useInsightsFilters(dashboard: InsightsDashboardID): UseInsightsFiltersR ...DEFAULT_INSIGHTS_FILTERS, groupCurrency: activePolicy?.outputCurrency ?? CONST.CURRENCY.USD, }; - const filters: InsightsFilters = {...defaultFilters, ...parseInsightsFilters(storedQuery)}; + const filters = withFittingGroupBy({...defaultFilters, ...parseInsightsFilters(storedQuery)}); const setFilters = (update: Partial) => { - setInsightsFilters(searchKey, buildInsightsQueryString({...filters, ...update})); + setInsightsFilters(searchKey, buildInsightsQueryString(withFittingGroupBy({...filters, ...update}))); }; return { diff --git a/tests/ui/InsightsControlsTest.tsx b/tests/ui/InsightsControlsTest.tsx index e0d32e742b8d..84f9f3390e0f 100644 --- a/tests/ui/InsightsControlsTest.tsx +++ b/tests/ui/InsightsControlsTest.tsx @@ -10,6 +10,7 @@ import InsightsGroupCurrencyControl from '@pages/Insights/controls/InsightsGroup import InsightsPageControls from '@pages/Insights/controls/InsightsPageControls'; import InsightsWorkspaceControl from '@pages/Insights/controls/InsightsWorkspaceControl'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import {getInsightsGroupByOptions} from '@pages/Insights/insightsGroupByOptions'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -122,6 +123,7 @@ describe('Insights controls', () => { renderWithProviders( , ); @@ -142,6 +144,7 @@ describe('Insights controls', () => { renderWithProviders( , ); @@ -155,6 +158,47 @@ describe('Insights controls', () => { expect(onChange).toHaveBeenCalledWith(CONST.SEARCH.GROUP_BY.MONTH); }); + it('only offers the groupings that fit the date range', async () => { + // Given the headline chart grouped by week over this month, which is too short for Month, Quarter and Year + const date: InsightsFilters['date'] = {preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}; + renderWithProviders( + , + ); + + // When the user opens the control + await openPill(/: Week$/); + + // Then the groupings that would plot a single point are hidden + expect(screen.getByText(/^(Day|search\.filters\.groupBy\.day)$/)).toBeOnTheScreen(); + expect(screen.queryByText(/^(Month|search\.filters\.groupBy\.month)$/)).toBeNull(); + expect(screen.queryByText(/^(Quarter|search\.filters\.groupBy\.quarter)$/)).toBeNull(); + }); + + it('resets the grouping to the nearest fit for month when the date range rules month out', async () => { + // Given the headline chart grouped by day over this month, which doesn't offer Month + const onChange = jest.fn(); + const date: InsightsFilters['date'] = {preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}; + renderWithProviders( + , + ); + + // When the user resets the control + await openPill(/: Day$/); + fireEvent.press(screen.getByText(RESET)); + await waitForBatchedUpdatesWithAct(); + + // Then the chart goes to Week, the closest offered grouping to the monthly default + expect(onChange).toHaveBeenCalledWith(CONST.SEARCH.GROUP_BY.WEEK); + }); + it('resets Workspace to every workspace', async () => { // Given a dashboard narrowed to one workspace const onChange = jest.fn(); diff --git a/tests/unit/hooks/useInsightsFilters.test.ts b/tests/unit/hooks/useInsightsFilters.test.ts index c342c2963900..2e0a74989730 100644 --- a/tests/unit/hooks/useInsightsFilters.test.ts +++ b/tests/unit/hooks/useInsightsFilters.test.ts @@ -42,12 +42,41 @@ async function getStoredQuery(): Promise { return typeof searchFilter === 'object' ? (searchFilter as {query: string}).query : undefined; } +async function storeFilters(filters: InsightsFilters) { + await Onyx.merge(ONYXKEYS.SEARCH_FILTERS, {[SPEND_SEARCH_KEY]: {query: buildInsightsQueryString(filters)}}); + await waitForBatchedUpdates(); +} + describe('useInsightsFilters', () => { + afterEach(() => { + jest.useRealTimers(); + }); + beforeAll(() => { Onyx.init({keys: ONYXKEYS}); }); beforeEach(async () => { + // Only the clock is faked, so the presets cover a known range while Onyx keeps its real timers + jest.useFakeTimers({ + now: new Date(2026, 9, 8), + doNotFake: [ + 'nextTick', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + 'queueMicrotask', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'requestIdleCallback', + 'cancelIdleCallback', + 'hrtime', + 'performance', + ], + }); await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -73,7 +102,7 @@ describe('useInsightsFilters', () => { // Given a dashboard last left on a single workspace, grouped by quarter, in USD await setUpActivePolicy('PLN'); const stored: InsightsFilters = { - date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}, + date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: ['A1'], groupBy: CONST.SEARCH.GROUP_BY.QUARTER, groupCurrency: 'USD', @@ -126,7 +155,7 @@ describe('useInsightsFilters', () => { await setUpActivePolicy('PLN'); await Onyx.merge(ONYXKEYS.SEARCH_FILTERS, { [SPEND_SEARCH_KEY]: { - query: buildInsightsQueryString({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_MONTH}, policyIDs: ['A1'], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: 'USD'}), + query: buildInsightsQueryString({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: ['A1'], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: 'USD'}), }, }); await waitForBatchedUpdates(); @@ -140,6 +169,59 @@ describe('useInsightsFilters', () => { }); // Then the stored query carries the new bucket and everything the other controls had already narrowed - expect(await getStoredQuery()).toBe('groupBy:quarter groupCurrency:USD policyID:A1 date:last-month'); + expect(await getStoredQuery()).toBe('groupBy:quarter groupCurrency:USD policyID:A1 date:last-12-months'); + }); + + it('opens on the nearest fitting grouping when the stored one no longer fits the date range', async () => { + // Given a dashboard stored as this month grouped by quarter, which plots a single point + await setUpActivePolicy('PLN'); + await storeFilters({date: {preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.QUARTER, groupCurrency: 'USD'}); + + // When the dashboard resolves its filters + const {result} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); + await waitFor(() => expect(result.current.isResolved).toBe(true)); + + // Then it groups by week, the closest grouping to quarter that this month offers + expect(result.current.filters.groupBy).toBe(CONST.SEARCH.GROUP_BY.WEEK); + }); + + it('saves the nearest fitting grouping when a new date range rules out the selected one, and keeps it after', async () => { + // Given a dashboard over the last 12 months grouped by quarter + await setUpActivePolicy('PLN'); + await storeFilters({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.QUARTER, groupCurrency: 'USD'}); + const {result} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); + await waitFor(() => expect(result.current.isResolved).toBe(true)); + + // When the date is changed to this month, then back to the last 12 months + await act(async () => { + result.current.setFilters({date: {preset: CONST.SEARCH.DATE_PRESETS.THIS_MONTH}}); + await waitForBatchedUpdates(); + }); + const groupByOnThisMonth = (await getStoredQuery())?.split(' ').at(0); + await act(async () => { + result.current.setFilters({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}}); + await waitForBatchedUpdates(); + }); + + // Then week is saved as the new selection, so going back to a range that offers quarter doesn't restore it + expect(groupByOnThisMonth).toBe('groupBy:week'); + expect(result.current.filters.groupBy).toBe(CONST.SEARCH.GROUP_BY.WEEK); + }); + + it('keeps the saved grouping on a single day, which has no grouping to pick', async () => { + // Given a dashboard over the last 12 months grouped by quarter + await setUpActivePolicy('PLN'); + await storeFilters({date: {preset: CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.QUARTER, groupCurrency: 'USD'}); + const {result} = renderHook(() => useInsightsFilters(CONST.INSIGHTS.DASHBOARD.SPEND)); + await waitFor(() => expect(result.current.isResolved).toBe(true)); + + // When the date is changed to a single day + await act(async () => { + result.current.setFilters({date: {on: '2026-03-04'}}); + await waitForBatchedUpdates(); + }); + + // Then the grouping stays quarter, ready for when a range is picked again + expect(result.current.filters.groupBy).toBe(CONST.SEARCH.GROUP_BY.QUARTER); }); }); diff --git a/tests/unit/insightsGroupByOptionsTest.ts b/tests/unit/insightsGroupByOptionsTest.ts new file mode 100644 index 000000000000..2d1492377ec1 --- /dev/null +++ b/tests/unit/insightsGroupByOptionsTest.ts @@ -0,0 +1,93 @@ +import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import {getFittingGroupBy, getInsightsGroupByOptions} from '@pages/Insights/insightsGroupByOptions'; + +import CONST from '@src/CONST'; + +const {DAY, WEEK, MONTH, QUARTER, YEAR} = CONST.SEARCH.GROUP_BY; +const {THIS_MONTH, LAST_MONTH, YEAR_TO_DATE, LAST_12_MONTHS} = CONST.SEARCH.DATE_PRESETS; + +describe('insightsGroupByOptions', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2026, 9, 8)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('getInsightsGroupByOptions', () => { + it.each<[string, InsightsFilters['date'], Array]>([ + ['this month', {preset: THIS_MONTH}, [DAY, WEEK]], + ['last month', {preset: LAST_MONTH}, [DAY, WEEK]], + ['year to date in October', {preset: YEAR_TO_DATE}, [WEEK, MONTH, QUARTER]], + ['last 12 months', {preset: LAST_12_MONTHS}, [WEEK, MONTH, QUARTER]], + ['a range under two weeks', {from: '2026-03-01', to: '2026-03-10'}, [DAY]], + ['a range of exactly two months', {from: '2026-01-01', to: '2026-02-28'}, [DAY, WEEK, MONTH]], + ['a range just over 62 days', {from: '2026-01-01', to: '2026-03-04'}, [WEEK, MONTH]], + ['a range of two years', {from: '2024-01-01', to: '2025-12-31'}, [WEEK, MONTH, QUARTER, YEAR]], + ['a single day', {on: '2026-03-04'}, []], + ])('offers the options that fit %s', (_label, date, expected) => { + // Given a dashboard reporting on that date range + + // When the Group by options are listed + const options = getInsightsGroupByOptions(date); + + // Then only groupings that give a readable trend for its length are offered, in the dropdown's order + expect(options).toEqual(expected); + }); + + it('offers Day and Week for year to date in January', () => { + // Given it's late January, so the year so far is a few weeks long + jest.setSystemTime(new Date(2026, 0, 20)); + + // When the Group by options are listed for year to date + const options = getInsightsGroupByOptions({preset: YEAR_TO_DATE}); + + // Then the range is too short for monthly points and short enough for daily ones + expect(options).toEqual([DAY, WEEK]); + }); + }); + + describe('getFittingGroupBy', () => { + it('keeps a Group by the date range offers', () => { + // Given a monthly chart over the last 12 months, which offers Month + + // When the Group by is fitted to the range + const groupBy = getFittingGroupBy(MONTH, getInsightsGroupByOptions({preset: LAST_12_MONTHS})); + + // Then the selection stays as it is + expect(groupBy).toBe(MONTH); + }); + + it('switches to the nearest offered option when the range rules the selection out', () => { + // Given a quarterly chart moved to this month, which only offers Day and Week + + // When the Group by is fitted to the range + const groupBy = getFittingGroupBy(QUARTER, getInsightsGroupByOptions({preset: THIS_MONTH})); + + // Then Week is picked, the closest grouping to Quarter that the month offers + expect(groupBy).toBe(WEEK); + }); + + it('switches Day to Week when the range is too long for daily points', () => { + // Given a daily chart moved to the last 12 months + + // When the Group by is fitted to the range + const groupBy = getFittingGroupBy(DAY, getInsightsGroupByOptions({preset: LAST_12_MONTHS})); + + // Then Week is picked, the closest grouping to Day that the range offers + expect(groupBy).toBe(WEEK); + }); + + it('keeps the selection for a single day, which has no options to pick from', () => { + // Given a quarterly chart moved to a single day + + // When the Group by is fitted to the day + const groupBy = getFittingGroupBy(QUARTER, getInsightsGroupByOptions({on: '2026-03-04'})); + + // Then the saved selection is left alone, so it comes back when a range is picked again + expect(groupBy).toBe(QUARTER); + }); + }); +}); From 614699b5559cf77824b499e34fb0660ea23c4696 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:59:00 +0100 Subject: [PATCH 357/781] make rest of agents pages dynamic --- src/ROUTES.ts | 83 ++++++++++--------- src/SCREENS.ts | 12 +-- .../ModalStackNavigators/index.tsx | 12 +-- .../RELATIONS/SETTINGS_TO_RHP.ts | 12 +-- src/libs/Navigation/linkingConfig/config.ts | 24 +++--- src/libs/Navigation/types.ts | 12 +-- .../replaceOptimisticAgentWithActualAgent.ts | 6 +- src/pages/settings/Agents/AgentsPage.tsx | 5 +- ...dAgentPage.tsx => DynamicAddAgentPage.tsx} | 26 +++--- .../settings/Agents/DynamicEditAgentPage.tsx | 7 +- ...wAgentPage.tsx => DynamicNewAgentPage.tsx} | 20 +++-- ...Page.tsx => DynamicAddAgentAvatarPage.tsx} | 13 +-- ...age.tsx => DynamicEditAgentAvatarPage.tsx} | 8 +- ...itNamePage.tsx => DynamicEditNamePage.tsx} | 14 ++-- ...omptPage.tsx => DynamicEditPromptPage.tsx} | 16 ++-- src/pages/workspace/WorkspaceAgentsPage.tsx | 5 +- ...placeOptimisticAgentWithActualAgentTest.ts | 2 +- 17 files changed, 148 insertions(+), 129 deletions(-) rename src/pages/settings/Agents/{AddAgentPage.tsx => DynamicAddAgentPage.tsx} (93%) rename src/pages/settings/Agents/{NewAgentPage.tsx => DynamicNewAgentPage.tsx} (89%) rename src/pages/settings/Agents/Fields/{AddAgentAvatarPage.tsx => DynamicAddAgentAvatarPage.tsx} (81%) rename src/pages/settings/Agents/Fields/{EditAgentAvatarPage.tsx => DynamicEditAgentAvatarPage.tsx} (96%) rename src/pages/settings/Agents/Fields/{EditNamePage.tsx => DynamicEditNamePage.tsx} (86%) rename src/pages/settings/Agents/Fields/{EditPromptPage.tsx => DynamicEditPromptPage.tsx} (90%) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2ba41fa89bbe..8ab3e76cb56a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -686,8 +686,8 @@ const DYNAMIC_ROUTES = { path: 'avatar-crop', entryScreens: [ SCREENS.SETTINGS.PROFILE.AVATAR, - SCREENS.SETTINGS.AGENTS.EDIT_AVATAR, - SCREENS.SETTINGS.AGENTS.ADD_AVATAR, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR, + SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR, SCREENS.NEW_CHAT.NEW_CHAT_CONFIRM, SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, @@ -2091,11 +2091,55 @@ const DYNAMIC_ROUTES = { }, queryParams: ['action', 'iouType', 'transactionID', 'reportID', 'upgradeBackTo', 'shouldSubmitExpense', 'upgradePath'], }, + AGENT_NEW: { + path: 'new', + getRoute: ({policyID}: {policyID?: string} = {}) => { + const params = new URLSearchParams(); + if (policyID) { + params.set('policyID', policyID); + } + const query = params.toString(); + return `new${query ? `?${query}` : ''}` as const; + }, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, + AGENT_ADD: { + path: 'new/custom', + getRoute: ({policyID}: {policyID?: string} = {}) => { + const params = new URLSearchParams(); + if (policyID) { + params.set('policyID', policyID); + } + const query = params.toString(); + return `new/custom${query ? `?${query}` : ''}` as const; + }, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, + AGENT_ADD_AVATAR: { + path: 'new/custom/avatar', + getRoute: () => `new/custom/avatar` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, AGENT_EDIT: { path: ':accountID/edit', getRoute: (accountID: number) => `${accountID}/edit` as const, entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], }, + AGENT_EDIT_NAME: { + path: ':accountID/edit/name', + getRoute: (accountID: number) => `${accountID}/edit/name` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, + AGENT_EDIT_PROMPT: { + path: ':accountID/edit/prompt', + getRoute: (accountID: number) => `${accountID}/edit/prompt` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, + AGENT_EDIT_AVATAR: { + path: ':accountID/edit/avatar', + getRoute: (accountID: number) => `${accountID}/edit/avatar` as const, + entryScreens: [SCREENS.SETTINGS.AGENTS.ROOT, SCREENS.WORKSPACE.AGENTS], + }, } as const satisfies DynamicRoutes; const ROUTES = { @@ -2608,41 +2652,6 @@ const ROUTES = { SETTINGS_WALLET_TRAVEL_CVV: 'settings/wallet/travel-cvv', SETTINGS_WALLET_TRAVEL_CVV_VERIFY_ACCOUNT: `settings/wallet/travel-cvv/${VERIFY_ACCOUNT}`, SETTINGS_AGENTS: 'settings/agents', - SETTINGS_AGENTS_NEW: { - route: 'settings/agents/new', - getRoute: ({policyID}: {policyID?: string} = {}) => { - const params = new URLSearchParams(); - if (policyID) { - params.set('policyID', policyID); - } - const query = params.toString(); - return `settings/agents/new${query ? `?${query}` : ''}` as const; - }, - }, - SETTINGS_AGENTS_ADD: { - route: 'settings/agents/new/custom', - getRoute: ({policyID}: {policyID?: string} = {}) => { - const params = new URLSearchParams(); - if (policyID) { - params.set('policyID', policyID); - } - const query = params.toString(); - return `settings/agents/new/custom${query ? `?${query}` : ''}` as const; - }, - }, - SETTINGS_AGENTS_ADD_AVATAR: 'settings/agents/new/custom/avatar', - SETTINGS_AGENTS_EDIT_NAME: { - route: 'settings/agents/:accountID/edit/name', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit/name` as const, - }, - SETTINGS_AGENTS_EDIT_PROMPT: { - route: 'settings/agents/:accountID/edit/prompt', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit/prompt` as const, - }, - SETTINGS_AGENTS_EDIT_AVATAR: { - route: 'settings/agents/:accountID/edit/avatar', - getRoute: (accountID: number) => `settings/agents/${accountID}/edit/avatar` as const, - }, AGENT_REPORT: { route: 'settings/agents/r/:reportID', getRoute: (reportID: string) => `settings/agents/r/${reportID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cf488c6da99b..568e31db0015 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -140,13 +140,13 @@ const SCREENS = { AGENTS: { ROOT: 'Settings_Agents', - NEW: 'Settings_Agents_New', - ADD: 'Settings_Agents_Add', - ADD_AVATAR: 'Settings_Agents_Add_Avatar', + DYNAMIC_NEW: 'Dynamic_Settings_Agents_New', + DYNAMIC_ADD: 'Dynamic_Settings_Agents_Add', + DYNAMIC_ADD_AVATAR: 'Dynamic_Settings_Agents_Add_Avatar', DYNAMIC_EDIT: 'Dynamic_Settings_Agents_Edit', - EDIT_NAME: 'Settings_Agents_Edit_Name', - EDIT_PROMPT: 'Settings_Agents_Edit_Prompt', - EDIT_AVATAR: 'Settings_Agents_Edit_Avatar', + DYNAMIC_EDIT_NAME: 'Dynamic_Settings_Agents_Edit_Name', + DYNAMIC_EDIT_PROMPT: 'Dynamic_Settings_Agents_Edit_Prompt', + DYNAMIC_EDIT_AVATAR: 'Dynamic_Settings_Agents_Edit_Avatar', }, RULES: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index bd30307184a0..af46d9457b90 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -507,13 +507,13 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/UpdatePersonalBankAccountPage').default, [SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: () => require('../../../../pages/settings/Wallet/BankAccountPurposePage').default, [SCREENS.SETTINGS.RULES.ROOT]: () => require('../../../../pages/settings/Rules/ExpenseRulesPage').default, - [SCREENS.SETTINGS.AGENTS.NEW]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/NewAgentPage').default), - [SCREENS.SETTINGS.AGENTS.ADD]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/AddAgentPage').default), - [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/AddAgentAvatarPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicNewAgentPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicAddAgentPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicAddAgentAvatarPage').default), [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/DynamicEditAgentPage').default), - [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditNamePage').default), - [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditPromptPage').default), - [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/EditAgentAvatarPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicEditNamePage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicEditPromptPage').default), + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR]: withAgentAccessDenied(() => require('../../../../pages/settings/Agents/Fields/DynamicEditAgentAvatarPage').default), [SCREENS.SETTINGS.RULES.ADD]: () => require('../../../../pages/settings/Rules/AddRulePage').default, [SCREENS.SETTINGS.RULES.ADD_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddMerchantPage').default, [SCREENS.SETTINGS.RULES.ADD_RENAME_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddRenameMerchantPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index f0e604cf71bf..83a7400fa6f8 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -72,13 +72,13 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_BANK_ACCOUNT_PURPOSE, exact: true, }, - [SCREENS.SETTINGS.AGENTS.NEW]: { - path: ROUTES.SETTINGS_AGENTS_NEW.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW]: { + path: DYNAMIC_ROUTES.AGENT_NEW.path, exact: true, }, - [SCREENS.SETTINGS.AGENTS.ADD]: { - path: ROUTES.SETTINGS_AGENTS_ADD.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD]: { + path: DYNAMIC_ROUTES.AGENT_ADD.path, }, - [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: { - path: ROUTES.SETTINGS_AGENTS_ADD_AVATAR, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR]: { + path: DYNAMIC_ROUTES.AGENT_ADD_AVATAR.path, exact: true, }, [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: { @@ -390,20 +390,20 @@ const config: LinkingOptions['config'] = { accountID: Number, }, }, - [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: { - path: ROUTES.SETTINGS_AGENTS_EDIT_NAME.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME]: { + path: DYNAMIC_ROUTES.AGENT_EDIT_NAME.path, parse: { accountID: Number, }, }, - [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: { - path: ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT]: { + path: DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.path, parse: { accountID: Number, }, }, - [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: { - path: ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.route, + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR]: { + path: DYNAMIC_ROUTES.AGENT_EDIT_AVATAR.path, parse: { accountID: Number, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 2f7fd1992110..96850a6ec981 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -281,23 +281,23 @@ type SettingsNavigatorParamList = { subPage?: string; }; [SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: undefined; - [SCREENS.SETTINGS.AGENTS.NEW]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_NEW]: { policyID?: string; }; - [SCREENS.SETTINGS.AGENTS.ADD]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD]: { policyID?: string; }; - [SCREENS.SETTINGS.AGENTS.ADD_AVATAR]: undefined; + [SCREENS.SETTINGS.AGENTS.DYNAMIC_ADD_AVATAR]: undefined; [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT]: { accountID: number; }; - [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME]: { accountID: number; }; - [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT]: { accountID: number; }; - [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: { + [SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR]: { accountID: number; }; [SCREENS.SETTINGS.RULES.ADD]: undefined; diff --git a/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts index 3feabfd7a85a..7a73859b6269 100644 --- a/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts +++ b/src/libs/actions/replaceOptimisticAgentWithActualAgent.ts @@ -29,9 +29,9 @@ import Onyx from 'react-native-onyx'; const AGENT_SETTINGS_SCREENS = new Set([ SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT, - SCREENS.SETTINGS.AGENTS.EDIT_NAME, - SCREENS.SETTINGS.AGENTS.EDIT_PROMPT, - SCREENS.SETTINGS.AGENTS.EDIT_AVATAR, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_NAME, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_PROMPT, + SCREENS.SETTINGS.AGENTS.DYNAMIC_EDIT_AVATAR, ]); // Reports are only read inside the mapping callback, so connectWithoutView() is used. On app start the mapping can diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index 5978edf14847..5e855f041274 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -18,10 +18,11 @@ import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButton import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {View} from 'react-native'; @@ -45,7 +46,7 @@ function AgentsPage() { const newAgentButton = ( - ); + const newAgentButton = null; const bulkActionsButtonOptions: Array>> = [ { diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 8fca07c2f176..1b664bfa8ad5 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -42,7 +42,6 @@ import { clearUpdateMemberRoleError, clearWorkspaceOwnerChangeFlow, downloadMembersCSV, - openWorkspaceMembersPage, removeMembers, updateWorkspaceMembersRole, } from '@libs/actions/Policy/Member'; @@ -62,7 +61,6 @@ import { canEditWorkspaceSettings as canEditWorkspaceSettingsUtil, canMemberAssignRole, canMemberManageMemberWithRole, - canMemberRead, canMemberWrite, canRolePay, getConnectionExporters, @@ -70,18 +68,14 @@ import { getReimburserEmail, hasActiveExpensifyCard, isControlPolicy, - isDeletedPolicyEmployee, - isExpensifyTeam, isGroupPolicy, isPaidGroupPolicy, isPolicyApprover, isSubmitPolicy, PAYER_ROLES, - shouldFilterExpensifyTeam, } from '@libs/PolicyUtils'; import {getDisplayNameForParticipant, isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; import getShouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; -import {generateAccountID} from '@libs/UserUtils'; import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import {close} from '@userActions/Modal'; @@ -91,15 +85,14 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {PersonalDetails, PolicyEmployee} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; import {useIsFocused} from '@react-navigation/native'; import {createOutstandingReportsForPolicySelector} from '@selectors/Report'; -import React, {useCallback, useEffect, useEffectEvent, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -360,7 +353,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers updateMemberRoleInline(policy, login, accountID, currentRole, newRole); }; - const policyOwner = policy?.owner; const canAssignElevatedRoles = canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES); const invitedPrimaryToSecondaryLogins = useMemo(() => invertObject(policy?.primaryLoginsInvited ?? {}), [policy?.primaryLoginsInvited]); const isControlPolicyWithWideLayout = !shouldUseNarrowLayout && isControlPolicy(policy); diff --git a/src/types/onyx/Agent.ts b/src/types/onyx/Agent.ts index bd0f7bf80259..c13225ad111f 100644 --- a/src/types/onyx/Agent.ts +++ b/src/types/onyx/Agent.ts @@ -4,6 +4,7 @@ import type {ValueOf} from 'type-fest'; import type {Errors, OnyxValueWithOfflineFeedback} from './OnyxCommon'; +/** The agent owner account type */ type AgentOwnerType = ValueOf; /** Agent model */ diff --git a/tests/unit/pages/settings/AddAgentAvatarPageTest.tsx b/tests/unit/pages/settings/AddAgentAvatarPageTest.tsx index 1721db1b21db..57b6fe79c879 100644 --- a/tests/unit/pages/settings/AddAgentAvatarPageTest.tsx +++ b/tests/unit/pages/settings/AddAgentAvatarPageTest.tsx @@ -9,7 +9,7 @@ import AddAgentAvatarPage from '@pages/settings/Agents/Fields/DynamicAddAgentAva import type {OnSaveParams} from '@pages/settings/Agents/Fields/DynamicEditAgentAvatarPage'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as ReactNavigationNative from '@react-navigation/native'; diff --git a/tests/unit/pages/settings/AgentsTableRowTest.tsx b/tests/unit/pages/settings/AgentsTableRowTest.tsx index a82bb51c5c7e..01cabd64ec5e 100644 --- a/tests/unit/pages/settings/AgentsTableRowTest.tsx +++ b/tests/unit/pages/settings/AgentsTableRowTest.tsx @@ -8,7 +8,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type ReactNative from 'react-native'; diff --git a/tests/unit/pages/settings/NewAgentPageTest.tsx b/tests/unit/pages/settings/NewAgentPageTest.tsx index 4435c293cc17..d67a5f83e740 100644 --- a/tests/unit/pages/settings/NewAgentPageTest.tsx +++ b/tests/unit/pages/settings/NewAgentPageTest.tsx @@ -13,7 +13,7 @@ import NewAgentPage from '@pages/settings/Agents/DynamicNewAgentPage'; import {clearNewAgentAvatarDraft, clearNewAgentTemplate, setNewAgentTemplate} from '@userActions/Agent'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type SuggestedAgent from '@src/types/onyx/SuggestedAgent'; From 78ec091ade30adf4463dbba1a3b1e42938a08432 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:23:31 +0100 Subject: [PATCH 433/781] lint --- src/hooks/useAgents.ts | 2 +- src/hooks/useWorkspaceAgents.ts | 3 +-- src/pages/settings/Agents/AgentsPage.tsx | 1 + src/pages/workspace/WorkspaceAgentsPage.tsx | 3 --- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/hooks/useAgents.ts b/src/hooks/useAgents.ts index d3389ec16484..d830f06d7d35 100644 --- a/src/hooks/useAgents.ts +++ b/src/hooks/useAgents.ts @@ -1,6 +1,6 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import {TableHandle} from '@components/Table'; +import type {TableHandle} from '@components/Table'; import type {AgentRowData, AgentsTableColumnKey} from '@components/Tables/AgentsTable'; import {clearAgentDeleteError, clearAgentError, clearAgentUpdateError, deleteAgent, openAgentsPage} from '@libs/actions/Agent'; diff --git a/src/hooks/useWorkspaceAgents.ts b/src/hooks/useWorkspaceAgents.ts index 8e84457e9ac8..572053e2210f 100644 --- a/src/hooks/useWorkspaceAgents.ts +++ b/src/hooks/useWorkspaceAgents.ts @@ -2,7 +2,6 @@ import CONST from '@src/CONST'; import type {Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import useAgents from './useAgents'; import useNetwork from './useNetwork'; @@ -24,7 +23,7 @@ function useWorkspaceAgents(policy: OnyxEntry) { if (!isAgentOwnedByWorkspace && !isAgentMemberOfWorkspace) { return []; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line no-param-reassign agent.role = policyRoles.find((policyRole) => policyRole === agentMembership?.policyEmployee.role); return [agent]; }); diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index 62751be9ee93..900cc661ec4c 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -2,6 +2,7 @@ import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; +// eslint-disable-next-line no-restricted-imports import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; diff --git a/src/pages/workspace/WorkspaceAgentsPage.tsx b/src/pages/workspace/WorkspaceAgentsPage.tsx index 5d9e70dea0b5..4893cdc14555 100644 --- a/src/pages/workspace/WorkspaceAgentsPage.tsx +++ b/src/pages/workspace/WorkspaceAgentsPage.tsx @@ -1,4 +1,3 @@ -import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import RenderHTML from '@components/RenderHTML'; @@ -17,14 +16,12 @@ import useWorkspaceAgents from '@hooks/useWorkspaceAgents'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; From 1e9b7ccd8fb8243060375a1f2ec22e56fe77cda1 Mon Sep 17 00:00:00 2001 From: Linh Date: Sun, 11 Oct 2026 16:34:00 +0700 Subject: [PATCH 434/781] Remove getReportTransactions callers' reliance on the deprecated default in getPayMoneyRequestParams (Part 8) getPayMoneyRequestParams now takes the report's transactions as an explicit param instead of falling back to the deprecated Onyx.connect-backed global. payMoneyRequest and payInvoice forward it; components and hooks pass the transactions they already have, useHoldMenuSubmit adds useReportTransactions, and bulk pay groups allTransactions by report once before its loop. --- config/eslint/eslint.seatbelt.tsv | 1 - .../MoneyReportHeaderSecondaryActions.tsx | 2 + .../PayPrimaryAction.tsx | 2 + .../PayActionButton.tsx | 2 + .../ListItem/ActionCell/PayActionCell.tsx | 2 + src/hooks/useHoldMenuSubmit.ts | 3 ++ src/hooks/useSearchBulkActions.ts | 16 ++++++ src/hooks/useSelectionModePayment.ts | 2 + src/libs/actions/IOU/PayMoneyRequest.ts | 17 ++++-- src/libs/actions/Search.ts | 5 ++ tests/actions/IOUTest/PayMoneyRequestTest.ts | 52 +++++++++++++++---- 11 files changed, 89 insertions(+), 15 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 5fec781e7e52..4a635fa86685 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -595,7 +595,6 @@ "../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 "../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 "../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/actions/IOU/PayMoneyRequest.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2 "../../src/libs/actions/IOU/PayMoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/IOU/PayMoneyRequest.ts" "no-restricted-imports" 1 "../../src/libs/actions/IOU/PayMoneyRequest.ts" "no-restricted-syntax" 1 diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 941fbf534f52..feaae4fe94c3 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -209,6 +209,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, + invoiceReportTransactions: allTransactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -234,6 +235,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo paymentType: type, chatReport, iouReport: moneyRequestReport, + iouReportTransactions: allTransactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 8da49d2a3f78..d3486ca92b44 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -146,6 +146,7 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, + invoiceReportTransactions: transactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -171,6 +172,7 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction paymentType: type, chatReport, iouReport: moneyRequestReport, + iouReportTransactions: transactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx index 07856fdb2e3b..fe87858db3e8 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx @@ -91,6 +91,7 @@ function PayActionButton() { paymentMethodType: type, chatReport: currentChatReport, invoiceReport: iouReport, + invoiceReportTransactions: transactions, introSelected, currentUserAccountIDParam: currentUserAccountID, currentUserEmailParam: currentUserEmail, @@ -115,6 +116,7 @@ function PayActionButton() { paymentType: type, chatReport: currentChatReport, iouReport, + iouReportTransactions: transactions, introSelected, currentUserAccountID, currentUserLogin: currentUserDetails.login ?? '', diff --git a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx index 32c772abe7f4..94bfdaccaa8e 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx @@ -140,6 +140,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab paymentMethodType: type, chatReport, invoiceReport: iouReport, + invoiceReportTransactions: transactions, introSelected, currentUserAccountIDParam: currentUserAccountID, currentUserEmailParam: email ?? '', @@ -178,6 +179,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab chatReport: chatReportForPayment, isFallbackChatReport, iouReport, + iouReportTransactions: transactions, introSelected, currentUserAccountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/hooks/useHoldMenuSubmit.ts b/src/hooks/useHoldMenuSubmit.ts index b21e8da14ec1..e9f51000950e 100644 --- a/src/hooks/useHoldMenuSubmit.ts +++ b/src/hooks/useHoldMenuSubmit.ts @@ -26,6 +26,7 @@ import usePayChatReportActions from './usePayChatReportActions'; import usePermissions from './usePermissions'; import {usePersonalDetail} from './usePersonalDetails'; import usePolicy from './usePolicy'; +import useReportTransactions from './useReportTransactions'; type ActionHandledType = DeepValueOf; @@ -47,6 +48,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment const activePolicy = usePolicy(activePolicyID); const policy = usePolicy(moneyRequestReport?.policyID); const chatReportPolicy = usePolicy(chatReport?.policyID); + const moneyRequestReportTransactions = useReportTransactions(moneyRequestReport?.reportID); const getChatReportActions = usePayChatReportActions(chatReport, undefined); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); @@ -111,6 +113,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment paymentType, chatReport: currentChatReport, iouReport: currentMoneyRequestReport, + iouReportTransactions: moneyRequestReportTransactions, introSelected, currentUserAccountID: currentUserDetails.accountID, currentUserLogin: currentUserDetails.login ?? '', diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 1ed5db2350b5..cb98beae143e 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1694,6 +1694,19 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; }); + const onyxTransactionsByReportID = new Map(); + for (const transaction of Object.values(allTransactions ?? {})) { + if (!transaction?.reportID) { + continue; + } + const reportTransactions = onyxTransactionsByReportID.get(transaction.reportID); + if (reportTransactions) { + reportTransactions.push(transaction); + } else { + onyxTransactionsByReportID.set(transaction.reportID, [transaction]); + } + } + let paidReportCount = 0; for (const item of itemsToPay) { if (!item.reportID) { @@ -1708,6 +1721,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } const isItemInvoice = isInvoiceReport(iouReport); + const iouReportTransactions = onyxTransactionsByReportID.get(item.reportID) ?? []; const fallbackChatReportID = item.chatReportID ?? iouReport.chatReportID ?? iouReport.parentReportID; const fallbackPolicyID = iouReport.policyID ?? item.policyID; const {chatReport, isFallbackChatReport} = getChatReportWithFallback( @@ -1774,6 +1788,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { paymentMethodType: paymentItem.paymentType as PaymentMethodType, chatReport, invoiceReport: iouReport, + invoiceReportTransactions: iouReportTransactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -1803,6 +1818,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { paymentType: paymentItem.paymentType as PaymentMethodType, chatReport, iouReport, + iouReportTransactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 60aaa6b22843..6afa44b92463 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -193,6 +193,7 @@ function useSelectionModePayment({ paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, + invoiceReportTransactions: transactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -217,6 +218,7 @@ function useSelectionModePayment({ paymentType: type, chatReport, iouReport: moneyRequestReport, + iouReportTransactions: transactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 93ba0d50cf95..137b0a06b446 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -17,7 +17,6 @@ import { buildOptimisticCancelPaymentReportAction, buildOptimisticIOUReportAction, getReimbursableTotal, - getReportTransactions, getUnheldReimbursableTotal, hasHeldExpenses as hasHeldExpensesReportUtils, hasOutstandingChildRequest, @@ -63,6 +62,8 @@ type PayInvoiceArgs = { paymentMethodType: PaymentMethodType; chatReport: OnyxTypes.Report; invoiceReport: OnyxEntry; + /** Expenses currently on invoiceReport, used to decide the amount to pay and to unhold them. */ + invoiceReportTransactions: OnyxTypes.Transaction[]; introSelected: OnyxEntry; currentUserAccountIDParam: number; currentUserEmailParam: string; @@ -102,6 +103,8 @@ type PayMoneyRequestFunctionParams = { paymentType: PaymentMethodType; chatReport: OnyxTypes.Report; iouReport: OnyxEntry; + /** Expenses currently on iouReport, used to decide the amount to pay and to unhold them. */ + iouReportTransactions: OnyxTypes.Transaction[]; introSelected: OnyxEntry; currentUserAccountID: number; currentUserLogin: string; @@ -131,6 +134,7 @@ type PayMoneyRequestFunctionParams = { function getPayMoneyRequestParams({ initialChatReport, iouReport, + iouReportTransactions, recipient, paymentMethodType, full, @@ -158,6 +162,7 @@ function getPayMoneyRequestParams({ }: { initialChatReport: OnyxTypes.Report; iouReport: OnyxEntry; + iouReportTransactions: OnyxTypes.Transaction[]; recipient: Participant; paymentMethodType: PaymentMethodType; full: boolean; @@ -254,14 +259,13 @@ function getPayMoneyRequestParams({ chatReport = existingB2BInvoiceReport; } - const reportTransactions = getReportTransactions(iouReport?.reportID); const unheldReimbursableTotal = getUnheldReimbursableTotal(iouReport); let total = getReimbursableTotal(iouReport); - if (hasHeldExpensesReportUtils(reportTransactions) && !full && !!unheldReimbursableTotal) { + if (hasHeldExpensesReportUtils(iouReportTransactions) && !full && !!unheldReimbursableTotal) { total = unheldReimbursableTotal; } - const shouldMoveScanFailedTransactions = !!full && isExpenseReport(iouReport) && shouldSplitScanFailedTransactions(reportTransactions, iouReport); + const shouldMoveScanFailedTransactions = !!full && isExpenseReport(iouReport) && shouldSplitScanFailedTransactions(iouReportTransactions, iouReport); const optimisticIOUReportAction = buildOptimisticIOUReportAction({ type: CONST.IOU.REPORT_ACTION_TYPE.PAY, @@ -444,7 +448,6 @@ function getPayMoneyRequestParams({ // Optimistically unhold all transactions if we pay all requests if (full) { - const iouReportTransactions = getReportTransactions(iouReport?.reportID); for (const transaction of iouReportTransactions) { onyxData.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -873,6 +876,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { paymentType, chatReport, iouReport, + iouReportTransactions, introSelected, currentUserAccountID, currentUserLogin, @@ -915,6 +919,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { const {params: payMoneyRequestParams, onyxData} = getPayMoneyRequestParams({ initialChatReport: chatReport, iouReport, + iouReportTransactions, recipient, paymentMethodType: paymentType, full, @@ -1121,6 +1126,7 @@ function payInvoice({ paymentMethodType, chatReport, invoiceReport, + invoiceReportTransactions, introSelected, currentUserAccountIDParam, currentUserEmailParam, @@ -1160,6 +1166,7 @@ function payInvoice({ } = getPayMoneyRequestParams({ initialChatReport: chatReport, iouReport: invoiceReport, + iouReportTransactions: invoiceReportTransactions, recipient, paymentMethodType, full: true, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 6d329ab15fe8..b1d578817555 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -380,6 +380,7 @@ function handleActionButtonPress({ conciergeChat, getCurrencyDecimals, rules, + iouReportTransactions: allReportTransactions, }); return; case CONST.SEARCH.ACTION_TYPES.APPROVE: @@ -622,6 +623,8 @@ type GetPayActionCallbackParams = { conciergeChat: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; rules: OnyxCollection; + /** Expenses on the report being paid, as shown in the search results. */ + iouReportTransactions: Transaction[]; }; function getPayActionCallback({ @@ -652,6 +655,7 @@ function getPayActionCallback({ conciergeChat, getCurrencyDecimals, rules, + iouReportTransactions, }: GetPayActionCallbackParams) { if (!item.reportID) { Log.info('[SearchPay] Dropping row pay: item has no reportID'); @@ -684,6 +688,7 @@ function getPayActionCallback({ paymentType: lastPolicyPaymentMethod, chatReport: chatReportForPayment, iouReport: snapshotReport, + iouReportTransactions, introSelected, currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserLogin: currentUserLogin ?? '', diff --git a/tests/actions/IOUTest/PayMoneyRequestTest.ts b/tests/actions/IOUTest/PayMoneyRequestTest.ts index fadb94342190..03666b907dfe 100644 --- a/tests/actions/IOUTest/PayMoneyRequestTest.ts +++ b/tests/actions/IOUTest/PayMoneyRequestTest.ts @@ -25,7 +25,7 @@ import type Transaction from '@src/types/onyx/Transaction'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxEntry, OnyxInputValue} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxInputValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -93,6 +93,18 @@ const RORY_EMAIL = 'rory@expensifail.com'; const RORY_ACCOUNT_ID = 3; OnyxUpdateManager(); +/** Reads the expenses currently in Onyx for a report, i.e. what a component would pass as the report's transactions. */ +async function getReportTransactionsFromOnyx(reportID: string | undefined): Promise { + let allTransactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (transactions) => { + allTransactions = transactions; + }, + }); + return Object.values(allTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction && transaction.reportID === reportID); +} + describe('actions/IOU/PayMoneyRequest', () => { beforeAll(() => { Onyx.init({ @@ -237,17 +249,19 @@ describe('actions/IOU/PayMoneyRequest', () => { }); }), ) - .then(() => { + .then(async () => { mockFetch?.pause?.(); if (!chatReport || !iouReport) { return waitForBatchedUpdates(); } + const iouReportTransactions = await getReportTransactionsFromOnyx(iouReport.reportID); payMoneyRequest({ isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID, currentUserLogin, @@ -463,16 +477,18 @@ describe('actions/IOU/PayMoneyRequest', () => { }); }), ) - .then(() => { + .then(async () => { if (!chatReport || !expenseReport) { return waitForBatchedUpdates(); } + const iouReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); payMoneyRequest({ isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -643,17 +659,19 @@ describe('actions/IOU/PayMoneyRequest', () => { }); }), ) - .then(() => { + .then(async () => { mockFetch?.fail?.(); if (!chatReport || !expenseReport) { return waitForBatchedUpdates(); } + const iouReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); payMoneyRequest({ isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: 'ACH', chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -712,6 +730,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -813,6 +832,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -858,6 +878,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -971,6 +992,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport: partialPayChatReport, iouReport, + iouReportTransactions: [transaction1, transaction2], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1071,6 +1093,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport: expenseReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1173,11 +1196,12 @@ describe('actions/IOU/PayMoneyRequest', () => { return expenseReport; }; - const pay = (expenseReport: Report) => + const pay = (expenseReport: Report, iouReportTransactions: Transaction[]) => payMoneyRequest({ paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: RORY_ACCOUNT_ID, currentUserLogin: RORY_EMAIL, @@ -1201,7 +1225,7 @@ describe('actions/IOU/PayMoneyRequest', () => { const scanFailedTransaction = buildTransaction('scanFailed1', 0, true); const expenseReport = await setUpReport([validTransaction, scanFailedTransaction]); - const newReportID = pay(expenseReport); + const newReportID = pay(expenseReport, [validTransaction, scanFailedTransaction]); await waitForBatchedUpdates(); expect(newReportID).toBeDefined(); @@ -1219,7 +1243,7 @@ describe('actions/IOU/PayMoneyRequest', () => { const scanFailedTransaction = buildTransaction('scanFailed1', 0, true); const expenseReport = await setUpReport([scanFailedTransaction]); - const newReportID = pay(expenseReport); + const newReportID = pay(expenseReport, [scanFailedTransaction]); await waitForBatchedUpdates(); expect(newReportID).toBeUndefined(); @@ -1232,7 +1256,7 @@ describe('actions/IOU/PayMoneyRequest', () => { const scanFailedTransaction = buildTransaction('scanFailed1', -5000, true); const expenseReport = await setUpReport([validTransaction, scanFailedTransaction]); - const newReportID = pay(expenseReport); + const newReportID = pay(expenseReport, [validTransaction, scanFailedTransaction]); await waitForBatchedUpdates(); expect(newReportID).toBeUndefined(); @@ -1268,6 +1292,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1323,6 +1348,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1498,6 +1524,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1572,6 +1599,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1636,6 +1664,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -1679,6 +1708,7 @@ describe('actions/IOU/PayMoneyRequest', () => { paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, + iouReportTransactions: [], introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -2107,17 +2137,19 @@ describe('actions/IOU/PayMoneyRequest', () => { }, }), ) - .then(() => { + .then(async () => { // When the expense report is paid elsewhere (but really, any payment option would work) if (!chatReport || !expenseReport) { return waitForBatchedUpdates(); } + const iouReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); payMoneyRequest({ isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, @@ -2623,12 +2655,14 @@ describe('actions/IOU/PayMoneyRequest', () => { // When the expense report is paid elsewhere (but really, any payment option would work) if (chatReport && expenseReport) { mockFetch?.pause?.(); + const iouReportTransactions = await getReportTransactionsFromOnyx(expenseReport.reportID); payMoneyRequest({ isASAPSubmitBetaEnabled: false, conciergeChat: undefined, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport: expenseReport, + iouReportTransactions, introSelected: undefined, currentUserAccountID: CARLOS_ACCOUNT_ID, currentUserLogin: CARLOS_EMAIL, From de6c5a8d613b9003476c92f494ac75a83d47c00d Mon Sep 17 00:00:00 2001 From: Linh Date: Sun, 11 Oct 2026 17:10:09 +0700 Subject: [PATCH 435/781] chore: remove comment --- src/libs/actions/IOU/PayMoneyRequest.ts | 2 -- src/libs/actions/Search.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 137b0a06b446..6b8be8316975 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -62,7 +62,6 @@ type PayInvoiceArgs = { paymentMethodType: PaymentMethodType; chatReport: OnyxTypes.Report; invoiceReport: OnyxEntry; - /** Expenses currently on invoiceReport, used to decide the amount to pay and to unhold them. */ invoiceReportTransactions: OnyxTypes.Transaction[]; introSelected: OnyxEntry; currentUserAccountIDParam: number; @@ -103,7 +102,6 @@ type PayMoneyRequestFunctionParams = { paymentType: PaymentMethodType; chatReport: OnyxTypes.Report; iouReport: OnyxEntry; - /** Expenses currently on iouReport, used to decide the amount to pay and to unhold them. */ iouReportTransactions: OnyxTypes.Transaction[]; introSelected: OnyxEntry; currentUserAccountID: number; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index b1d578817555..9a88bedab095 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -623,7 +623,6 @@ type GetPayActionCallbackParams = { conciergeChat: OnyxEntry; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; rules: OnyxCollection; - /** Expenses on the report being paid, as shown in the search results. */ iouReportTransactions: Transaction[]; }; From 04ef058ac1d358a4aeb1f8c86e22a5f90b684691 Mon Sep 17 00:00:00 2001 From: Linh Date: Sun, 11 Oct 2026 18:11:41 +0700 Subject: [PATCH 436/781] Use derived report transactions lookup for bulk pay instead of looping allTransactions --- src/hooks/useSearchBulkActions.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index cb98beae143e..6fb2c69ffb42 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1694,19 +1694,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; }); - const onyxTransactionsByReportID = new Map(); - for (const transaction of Object.values(allTransactions ?? {})) { - if (!transaction?.reportID) { - continue; - } - const reportTransactions = onyxTransactionsByReportID.get(transaction.reportID); - if (reportTransactions) { - reportTransactions.push(transaction); - } else { - onyxTransactionsByReportID.set(transaction.reportID, [transaction]); - } - } - let paidReportCount = 0; for (const item of itemsToPay) { if (!item.reportID) { @@ -1721,7 +1708,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } const isItemInvoice = isInvoiceReport(iouReport); - const iouReportTransactions = onyxTransactionsByReportID.get(item.reportID) ?? []; + const iouReportTransactions = Object.values(allReportsTransactionsAndViolations?.[item.reportID]?.transactions ?? {}); const fallbackChatReportID = item.chatReportID ?? iouReport.chatReportID ?? iouReport.parentReportID; const fallbackPolicyID = iouReport.policyID ?? item.policyID; const {chatReport, isFallbackChatReport} = getChatReportWithFallback( @@ -1875,6 +1862,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { personalPolicyID, lastPaymentMethods, allTransactions, + allReportsTransactionsAndViolations, policyIDsWithVBBA, clearSelectedTransactions, accountID, From 86dfc510082d825ea46c0af120fc612079111e47 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:15:42 +0200 Subject: [PATCH 437/781] refactor: size charts from the card width before they mount --- jest/setup.ts | 2 + server/libs/bundleWithRnStubs.ts | 33 ++++ server/victory-chart-renderer/scripts/dev.ts | 35 +--- .../tests/plotBoundsDrift.test.ts | 70 +++++++ .../tests/probes/plotBoundsProbe.tsx | 9 + .../tests/probes/plotBoundsProbeMain.tsx | 70 +++++++ .../Charts/BarChart/BarChartContent.tsx | 4 +- .../BarChart/VerticalBarChartContent.tsx | 185 +++++++++--------- .../Charts/BarChart/index.native.tsx | 13 +- src/components/Charts/BarChart/index.tsx | 13 +- src/components/Charts/BarChart/types.ts | 6 + .../Charts/LineChart/LineChartContent.tsx | 178 ++++++++--------- .../Charts/LineChart/index.native.tsx | 13 +- src/components/Charts/LineChart/index.tsx | 13 +- .../Charts/PieChart/PieChartContent.tsx | 71 ++++--- .../Charts/PieChart/index.native.tsx | 13 +- src/components/Charts/PieChart/index.tsx | 13 +- .../Charts/components/ChartWidthBox.tsx | 28 +++ src/components/Charts/hooks/index.ts | 2 + .../Charts/hooks/useReadyCartesianCanvas.ts | 21 ++ src/components/Charts/utils/index.ts | 11 ++ .../Charts/chartWidthHandoff.test.tsx | 94 +++++++++ tests/unit/components/Charts/utils.test.ts | 25 +++ 23 files changed, 663 insertions(+), 259 deletions(-) create mode 100644 server/libs/bundleWithRnStubs.ts create mode 100644 server/victory-chart-renderer/tests/plotBoundsDrift.test.ts create mode 100644 server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx create mode 100644 server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx create mode 100644 src/components/Charts/components/ChartWidthBox.tsx create mode 100644 src/components/Charts/hooks/useReadyCartesianCanvas.ts create mode 100644 tests/unit/components/Charts/chartWidthHandoff.test.tsx diff --git a/jest/setup.ts b/jest/setup.ts index 9afaa8f219a1..762041ace642 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -370,6 +370,8 @@ jest.mock('victory-native', () => ({ CartesianChart: jest.fn( ({children}: {children?: (args: Record) => ReactNode}) => children?.({points: {y: []}, chartBounds: {left: 0, right: 0, top: 0, bottom: 0}}) ?? null, ), + Pie: {Chart: jest.fn(() => null)}, + PolarChart: jest.fn(() => null), })); // Provide a default global fetch mock for tests that do not explicitly set it up diff --git a/server/libs/bundleWithRnStubs.ts b/server/libs/bundleWithRnStubs.ts new file mode 100644 index 000000000000..448daf25f504 --- /dev/null +++ b/server/libs/bundleWithRnStubs.ts @@ -0,0 +1,33 @@ +import assertBuildSuccess from '@server/libs/assertBuildSuccess'; +import createRnStubPlugin from '@server/plugins/rnStubPlugin'; +import {basename, dirname, join, resolve} from 'node:path'; + +type BundleWithRnStubsOptions = { + packageRoot: string; + entrypoint: string; + outFile: string; +}; + +/** Every output is written beside outFile, because the bundle loads CanvasKit's wasm from beside itself. */ +async function bundleWithRnStubs({packageRoot, entrypoint, outFile}: BundleWithRnStubsOptions): Promise { + const buildResult = await Bun.build({ + entrypoints: [entrypoint], + target: 'bun', + packages: 'bundle', + conditions: ['react-native'], + tsconfig: join(packageRoot, 'tsconfig.json'), + plugins: [createRnStubPlugin(resolve(packageRoot, '../stubs'))], + }); + + assertBuildSuccess(buildResult, `Failed to bundle ${entrypoint}`); + + if (buildResult.outputs.length === 0) { + throw new Error(`Bundled output of ${entrypoint} is missing`); + } + + for (const output of buildResult.outputs) { + await Bun.write(output.kind === 'entry-point' ? outFile : join(dirname(outFile), basename(output.path)), output); + } +} + +export default bundleWithRnStubs; diff --git a/server/victory-chart-renderer/scripts/dev.ts b/server/victory-chart-renderer/scripts/dev.ts index 28e91ab32ffd..f71b01ec13a8 100644 --- a/server/victory-chart-renderer/scripts/dev.ts +++ b/server/victory-chart-renderer/scripts/dev.ts @@ -2,52 +2,25 @@ * Development runner for the victory-chart-renderer CLI. * * victory-native is hoisted to the App repo root, so a bare `bun run src/cli.tsx` would - * resolve real react-native native sources that Bun cannot bundle. This script bundles - * src/cli.tsx with rnStubPlugin (redirecting RN imports to server/stubs), writes the - * bundle to .dev/cli.js, then executes it under the current Bun binary. + * resolve real react-native native sources that Bun cannot bundle. * * process.argv entries after this script are forwarded unchanged to the bundled CLI. * The child process cwd is the App repository root (two levels above this package). * * For a standalone executable instead of a Bun-run bundle, use build.ts. */ -import assertBuildSuccess from '@server/libs/assertBuildSuccess'; -import createRnStubPlugin from '@server/plugins/rnStubPlugin'; +import bundleWithRnStubs from '@server/libs/bundleWithRnStubs'; import {spawnSync} from 'node:child_process'; import {mkdirSync} from 'node:fs'; -import {basename, join, resolve} from 'node:path'; +import {join, resolve} from 'node:path'; const packageRoot = resolve(import.meta.dir, '..'); const repoRoot = resolve(packageRoot, '../..'); -const stubRoot = resolve(packageRoot, '../stubs'); -const tsconfigPath = join(packageRoot, 'tsconfig.json'); const outFile = resolve(packageRoot, '.dev/cli.js'); mkdirSync(join(packageRoot, '.dev'), {recursive: true}); -const buildResult = await Bun.build({ - entrypoints: [resolve(packageRoot, 'src/bootstrap.tsx')], - target: 'bun', - packages: 'bundle', - conditions: ['react-native'], - tsconfig: tsconfigPath, - plugins: [createRnStubPlugin(stubRoot)], -}); - -assertBuildSuccess(buildResult, 'Failed to bundle victory-chart-renderer CLI'); - -if (buildResult.outputs.length === 0) { - throw new Error('Bundled CLI output is missing'); -} - -const devDir = join(packageRoot, '.dev'); - -for (const output of buildResult.outputs) { - const outputName = basename(output.path); - const destName = output.kind === 'entry-point' ? 'cli.js' : outputName; - - await Bun.write(join(devDir, destName), output); -} +await bundleWithRnStubs({packageRoot, entrypoint: resolve(packageRoot, 'src/bootstrap.tsx'), outFile}); const runResult = spawnSync(process.execPath, [outFile, ...process.argv.slice(2)], { cwd: repoRoot, diff --git a/server/victory-chart-renderer/tests/plotBoundsDrift.test.ts b/server/victory-chart-renderer/tests/plotBoundsDrift.test.ts new file mode 100644 index 000000000000..7a889732edd3 --- /dev/null +++ b/server/victory-chart-renderer/tests/plotBoundsDrift.test.ts @@ -0,0 +1,70 @@ +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; + +import type {ChartBounds} from 'victory-native'; + +import bundleWithRnStubs from '@server/libs/bundleWithRnStubs'; +import {spawnSync} from 'node:child_process'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {packageRoot} from './testUtils'; + +type PlotBounds = Pick; + +type ProbeOutput = { + predicted: PlotBounds; + reported: PlotBounds; +}; + +const runDir = mkdtempSync(join(tmpdir(), 'vcr-plot-bounds-')); +const bundlePath = join(runDir, 'plotBoundsProbe.js'); + +function hasPlotBounds(value: unknown): value is PlotBounds { + return typeof value === 'object' && value !== null && 'left' in value && typeof value.left === 'number' && 'right' in value && typeof value.right === 'number'; +} + +function parseProbeOutput(raw: string): ProbeOutput { + const parsed: unknown = JSON.parse(raw); + + if (typeof parsed !== 'object' || parsed === null || !('predicted' in parsed) || !('reported' in parsed) || !hasPlotBounds(parsed.predicted) || !hasPlotBounds(parsed.reported)) { + throw new Error(`Plot bounds probe printed unexpected output:\n${raw}`); + } + + return {predicted: parsed.predicted, reported: parsed.reported}; +} + +let probeOutput: ProbeOutput; + +describe('cartesian plot bounds', () => { + beforeAll(async () => { + await bundleWithRnStubs({ + packageRoot, + entrypoint: fileURLToPath(import.meta.resolve('./probes/plotBoundsProbe.tsx')), + outFile: bundlePath, + }); + + const runResult = spawnSync(process.execPath, [bundlePath], { + cwd: resolve(packageRoot, '../..'), + encoding: 'utf8', + }); + + if (runResult.status !== 0) { + throw new Error(`Plot bounds probe failed:\n${runResult.stderr}\n${runResult.stdout}`); + } + + probeOutput = parseProbeOutput(runResult.stdout); + }, 120_000); + + afterAll(() => { + rmSync(runDir, {recursive: true, force: true}); + }); + + test('should predict the plot bounds victory-native reports', () => { + // Given a real chart laid out the way the vertical bar chart is, whose padding and axes the line chart shares, drawn headless by the probe + // When its reported plot bounds are compared with the ones predicted from its width + // Then they match, so a victory-native upgrade that moves the plot fails here instead of misplacing labels + expect(probeOutput.predicted).toEqual(probeOutput.reported); + }); +}); diff --git a/server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx b/server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx new file mode 100644 index 000000000000..f4fd7087213d --- /dev/null +++ b/server/victory-chart-renderer/tests/probes/plotBoundsProbe.tsx @@ -0,0 +1,9 @@ +/* + * Runs as a bundled child process rather than inside the test process, because victory-native resolves + * react-native, which only loads through the stubs rnStubPlugin substitutes at bundle time. + */ +import initSkiaForCli from '../../src/initSkiaForCli'; + +// Skia must be initialized before the chart module and its transitive imports are evaluated. +await initSkiaForCli(); +await import('./plotBoundsProbeMain'); diff --git a/server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx b/server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx new file mode 100644 index 000000000000..32b82d58e425 --- /dev/null +++ b/server/victory-chart-renderer/tests/probes/plotBoundsProbeMain.tsx @@ -0,0 +1,70 @@ +import {VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants'; +import {getCartesianPlotBounds} from '@components/Charts/utils'; +import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; + +import type {ChartBounds} from 'victory-native'; + +import {drawOffscreen, makeOffscreenSurface} from '@shopify/react-native-skia/lib/module/headless'; +import {CartesianChart} from 'victory-native'; + +const CHART_WIDTH = 400; + +const Y_AXIS_LABEL_WIDTH = 37; + +const CHART_PADDING_RIGHT = Y_AXIS_LABEL_WIDTH + GLYPH_PADDING; + +const DATA = [ + {x: 0, y: 120}, + {x: 1, y: 80}, + {x: 2, y: 45}, +]; + +const captured: {bounds: ChartBounds | null} = {bounds: null}; + +const chartElement = ( + + {({chartBounds}) => { + // The bounds reach onChartBoundsChange from an effect, which the offscreen draw never flushes, so they + // are read here instead. Both come from the same value the chart computes for its own layout. + captured.bounds = chartBounds; + return null; + }} + +); + +using surface = makeOffscreenSurface(CHART_WIDTH, CHART_CONTENT_MIN_HEIGHT); +using image = await drawOffscreen(surface, chartElement); + +if (!image) { + throw new Error('Skia failed to draw the probe chart'); +} + +if (!captured.bounds) { + throw new Error('CartesianChart never reported its bounds'); +} + +const {left, right} = getCartesianPlotBounds(CHART_WIDTH, CHART_PADDING_RIGHT); + +console.log(JSON.stringify({predicted: {left, right}, reported: {left: captured.bounds.left, right: captured.bounds.right}})); diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 04112b6a66b5..bd489db5b24f 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -2,11 +2,11 @@ import {ChartFontsProvider} from '@components/Charts/hooks'; import React from 'react'; -import type BarChartProps from './types'; +import type {BarChartContentProps} from './types'; import VerticalBarChartContentBody from './VerticalBarChartContent'; -function BarChartContent(props: BarChartProps) { +function BarChartContent(props: BarChartContentProps) { return ( diff --git a/src/components/Charts/BarChart/VerticalBarChartContent.tsx b/src/components/Charts/BarChart/VerticalBarChartContent.tsx index 6daeefc70cc0..7eff40020f28 100644 --- a/src/components/Charts/BarChart/VerticalBarChartContent.tsx +++ b/src/components/Charts/BarChart/VerticalBarChartContent.tsx @@ -4,43 +4,41 @@ import ChartGridLines from '@components/Charts/components/ChartGridLines'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; -import type {HitTestArgs} from '@components/Charts/hooks'; +import type {CartesianCanvasInputs, HitTestArgs} from '@components/Charts/hooks'; import { - useChartFontManager, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useChartLabelMeasurements, useDynamicYDomain, useLabelHitTesting, + useReadyCartesianCanvas, } from '@components/Charts/hooks'; -import {getBarLayout, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; +import {getBarLayout, getCartesianPlotBounds, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import type {LayoutChangeEvent} from 'react-native'; import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native'; -import React, {useState} from 'react'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; import {Bar, CartesianChart} from 'victory-native'; import type BarChartProps from './types'; +import type {BarChartContentProps} from './types'; -function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onBarPress, shouldShowLabels = true}: BarChartProps) { +type VerticalBarChartCanvasProps = Omit & CartesianCanvasInputs; + +function VerticalBarChartCanvas({data, yAxisUnit, yAxisUnitPosition = 'left', onBarPress, shouldShowLabels = true, chartWidth, fontManager}: VerticalBarChartCanvasProps) { const theme = useTheme(); const styles = useThemeStyles(); - const fontManager = useChartFontManager(); - const [chartWidth, setChartWidth] = useState(0); - const [barAreaWidth, setBarAreaWidth] = useState(0); - const [boundsLeft, setBoundsLeft] = useState(0); - const [boundsRight, setBoundsRight] = useState(0); + const StyleUtils = useStyleUtils(); const chartData = data.map((point, index) => ({ x: index, @@ -59,16 +57,21 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit } }; - const handleLayout = (event: LayoutChangeEvent) => { - setChartWidth(event.nativeEvent.layout.width); - }; - - const barLayout = getBarLayout(barAreaWidth, data.length); - // Empty label data makes the measurement and layout hooks return early instead of laying out labels nobody sees. const labelData = shouldShowLabels ? data : []; const originalLabels = labelData.map(getXAxisLabel); + const {formatValue, formatCompactValue} = useChartLabelFormats({ + data, + unit: yAxisUnit, + unitPosition: yAxisUnitPosition, + }); + + const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, VERTICAL_BAR_DOMAIN_PADDING); + const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING; + const plotBounds = getCartesianPlotBounds(chartWidth, chartPaddingRight); + const barLayout = getBarLayout(plotBounds.width, data.length); + const measurements = useChartLabelMeasurements(labelData, fontManager, variables.iconSizeExtraSmall); const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = useChartLabelLayout({ @@ -76,16 +79,10 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit fontManager, fontSize: variables.iconSizeExtraSmall, measurements, - tickSpacing: shouldShowLabels && barAreaWidth > 0 ? barLayout.barWidth + barLayout.gap : 0, - labelAreaWidth: barAreaWidth, - firstTickLeftSpace: boundsLeft + barLayout.edgeSpace, - lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + barLayout.edgeSpace : 0, - }); - - const {formatValue, formatCompactValue} = useChartLabelFormats({ - data, - unit: yAxisUnit, - unitPosition: yAxisUnitPosition, + tickSpacing: shouldShowLabels && plotBounds.width > 0 ? barLayout.barWidth + barLayout.gap : 0, + labelAreaWidth: plotBounds.width, + firstTickLeftSpace: plotBounds.left + barLayout.edgeSpace, + lastTickRightSpace: chartWidth - plotBounds.right + barLayout.edgeSpace, }); const barHitHalfWidth = useSharedValue(0); @@ -110,9 +107,6 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit plotTop.set(bounds.top); plotBottom.set(bounds.bottom); yZero.set(0); - setBarAreaWidth(domainWidth); - setBoundsLeft(bounds.left); - setBoundsRight(bounds.right); }; const checkIsOverBar = (args: HitTestArgs) => { @@ -167,7 +161,7 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit }; const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { - if (!fontManager || xAxisLabelHeight === undefined) { + if (xAxisLabelHeight === undefined) { return null; } @@ -207,73 +201,56 @@ function VerticalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosit }; const labelSpace = shouldShowLabels ? VictoryTheme.axis.xAxisLabelGap + (xAxisLabelHeight ?? 0) : 0; - const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; - const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, VERTICAL_BAR_DOMAIN_PADDING); - const chartPadding = {...VictoryTheme.axis.padding, bottom: labelSpace + VictoryTheme.axis.padding.bottom, right: yAxisLabelWidth + GLYPH_PADDING}; - - if (isLoading || !fontManager) { - return ( - - - - ); - } - - if (data.length === 0) { - return null; - } + const chartHeight = CHART_CONTENT_MIN_HEIGHT + labelSpace; + const chartPadding = {...VictoryTheme.axis.padding, bottom: labelSpace + VictoryTheme.axis.padding.bottom, right: chartPaddingRight}; return ( - - {chartWidth > 0 && ( - - {({points, chartBounds, yScale, yTicks}) => ( - <> - - {points.y.map((point) => renderBar(point, chartBounds))} - - )} - - )} + + + {({points, chartBounds, yScale, yTicks}) => ( + <> + + {points.y.map((point) => renderBar(point, chartBounds))} + + )} + + + + ); + } + + return ( + + ); +} + export default VerticalBarChartContentBody; diff --git a/src/components/Charts/BarChart/index.native.tsx b/src/components/Charts/BarChart/index.native.tsx index 63798b3dc21b..9aea26a2ab8a 100644 --- a/src/components/Charts/BarChart/index.native.tsx +++ b/src/components/Charts/BarChart/index.native.tsx @@ -1,3 +1,5 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; + import React from 'react'; import type BarChartProps from './types'; @@ -5,7 +7,16 @@ import type BarChartProps from './types'; import BarChartContent from './BarChartContent'; function BarChart(props: BarChartProps) { - return ; + return ( + + {(chartWidth) => ( + + )} + + ); } export default BarChart; diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index 50d908c8083e..e3b165f0a3b5 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -1,3 +1,4 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; import SkiaWebChart from '@components/Charts/SkiaWebChart'; import React from 'react'; @@ -7,10 +8,14 @@ import type BarChartProps from './types'; const getBarChartContent = () => import('./BarChartContent'); function BarChart(props: BarChartProps) { return ( - + + {(chartWidth) => ( + + )} + ); } diff --git a/src/components/Charts/BarChart/types.ts b/src/components/Charts/BarChart/types.ts index 064e1322dc25..88eee9d07669 100644 --- a/src/components/Charts/BarChart/types.ts +++ b/src/components/Charts/BarChart/types.ts @@ -7,4 +7,10 @@ type BarChartProps = CartesianChartProps & { shouldShowLabels?: boolean; }; +type BarChartContentProps = BarChartProps & { + /** Null until the card has been measured. */ + chartWidth: number | null; +}; + export default BarChartProps; +export type {BarChartContentProps}; diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index a2375540b752..c5bd5165a838 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -5,30 +5,29 @@ import ChartGridLines from '@components/Charts/components/ChartGridLines'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; -import type {HitTestArgs} from '@components/Charts/hooks'; +import type {CartesianCanvasInputs, HitTestArgs} from '@components/Charts/hooks'; import { ChartFontsProvider, - useChartFontManager, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useChartLabelMeasurements, useDynamicYDomain, useLabelHitTesting, + useReadyCartesianCanvas, } from '@components/Charts/hooks'; -import {getDomainPaddingForEdgeSpace, getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; +import {getCartesianPlotBounds, getDomainPaddingForEdgeSpace, getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, DASH_INTERVALS, GLYPH_PADDING, LABEL_PADDING, LABEL_ROTATIONS, SIN_45} from '@components/Charts/VictoryTheme'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import type {LayoutChangeEvent} from 'react-native'; import type {CartesianChartRenderArg, ChartBounds, Scale} from 'victory-native'; import {DashPathEffect} from '@shopify/react-native-skia'; -import React, {useState} from 'react'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue} from 'react-native-reanimated'; @@ -40,14 +39,17 @@ type LineChartProps = CartesianChartProps & { onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; }; -function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { +type LineChartContentProps = LineChartProps & { + /** Null until the card has been measured. */ + chartWidth: number | null; +}; + +type LineChartCanvasProps = Omit & CartesianCanvasInputs; + +function LineChartCanvas({data, yAxisUnit, yAxisUnitPosition = 'left', onPointPress, chartWidth, fontManager}: LineChartCanvasProps) { const theme = useTheme(); const styles = useThemeStyles(); - const fontManager = useChartFontManager(); - const [chartWidth, setChartWidth] = useState(0); - const [plotAreaWidth, setPlotAreaWidth] = useState(0); - const [boundsLeft, setBoundsLeft] = useState(0); - const [boundsRight, setBoundsRight] = useState(0); + const StyleUtils = useStyleUtils(); const yAxisDomain = useDynamicYDomain(data); const isLastPointInProgress = !!data.at(-1)?.isInProgress; @@ -69,10 +71,6 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' } }; - const handleLayout = (event: LayoutChangeEvent) => { - setChartWidth(event.nativeEvent.layout.width); - }; - const chartBottom = useSharedValue(0); const plotTop = useSharedValue(0); const plotLeft = useSharedValue(0); @@ -90,8 +88,9 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' const yAxisLabelWidth = getYAxisLabelWidth(data, formatCompactValue, fontManager, variables.iconSizeExtraSmall, VictoryTheme.line.domainPadding); - const tickSpacing = plotAreaWidth > 0 && data.length > 0 ? plotAreaWidth / data.length : 0; const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING; + const {left: boundsLeft, right: boundsRight, width: plotAreaWidth} = getCartesianPlotBounds(chartWidth, chartPaddingRight); + const tickSpacing = plotAreaWidth > 0 && data.length > 0 ? plotAreaWidth / data.length : 0; const domainPadding = (() => { if (!firstLabelWidth || !lastLabelWidth) { @@ -124,7 +123,7 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' tickSpacing, labelAreaWidth: plotAreaWidth, firstTickLeftSpace: boundsLeft + domainPadding.left * paddingScale, - lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + domainPadding.right * paddingScale : 0, + lastTickRightSpace: chartWidth - boundsRight + domainPadding.right * paddingScale, measurements, }); @@ -143,9 +142,6 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' plotTop.set(bounds.top); plotLeft.set(bounds.left); plotRight.set(bounds.right); - setPlotAreaWidth(bounds.right - bounds.left); - setBoundsLeft(bounds.left); - setBoundsRight(bounds.right); }; const labelSpace = VictoryTheme.axis.xAxisLabelGap + (xAxisLabelHeight ?? 0); @@ -223,7 +219,7 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' )} - {xAxisLabelHeight !== undefined && !!fontManager && ( + {xAxisLabelHeight !== undefined && ( - - - ); - } - - if (data.length === 0) { - return null; - } - return ( - - {chartWidth > 0 && ( - - {({yScale, yTicks, chartBounds}) => ( - - )} - - )} + + + {({yScale, yTicks, chartBounds}) => ( + + )} + ; + return ( + + {(chartWidth) => ( + + )} + + ); } LineChart.displayName = 'LineChart'; diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx index 7de73f7fcf44..01d3da19af53 100644 --- a/src/components/Charts/LineChart/index.tsx +++ b/src/components/Charts/LineChart/index.tsx @@ -1,3 +1,4 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; import SkiaWebChart from '@components/Charts/SkiaWebChart'; import React from 'react'; @@ -7,10 +8,14 @@ import type {LineChartProps} from './LineChartContent'; const getLineChartContent = () => import('./LineChartContent'); function LineChart(props: LineChartProps) { return ( - + + {(chartWidth) => ( + + )} + ); } diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index efc9c2a6654f..001033ef9c6a 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -3,15 +3,13 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {TOOLTIP_BAR_GAP, useChartLabelFormats, useTooltipData} from '@components/Charts/hooks'; import type {ChartDataPoint, ChartProps, PieSlice, UnitPosition} from '@components/Charts/types'; import {findSliceAtPosition, processDataIntoSlices} from '@components/Charts/utils'; -import VictoryTheme from '@components/Charts/VictoryTheme'; +import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {LayoutChangeEvent} from 'react-native'; - import React, {useState} from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; @@ -34,12 +32,19 @@ type PieChartProps = ChartProps & { shouldShowLegend?: boolean; }; -function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlicePress, shouldShowLegend = true}: PieChartProps) { +type PieChartContentProps = PieChartProps & { + /** Null until the card has been measured. */ + chartWidth: number | null; +}; + +type PieChartCanvasProps = Omit & { + chartWidth: number; +}; + +function PieChartCanvas({data, valueUnit, valueUnitPosition, onSlicePress, shouldShowLegend = true, chartWidth}: PieChartCanvasProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const [canvasWidth, setCanvasWidth] = useState(0); - const [canvasHeight, setCanvasHeight] = useState(0); const [activeSliceIndex, setActiveSliceIndex] = useState(-1); const [isHoveringOverPie, setIsHoveringOverPie] = useState(false); @@ -49,15 +54,10 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice const cursorY = useSharedValue(0); const tooltipPosition = useSharedValue({x: 0, y: 0}); - const handleLayout = (event: LayoutChangeEvent) => { - setCanvasWidth(event.nativeEvent.layout.width); - setCanvasHeight(event.nativeEvent.layout.height); - }; - - // Calculate pie geometry - const radius = Math.min(canvasWidth, canvasHeight) / 2; + const canvasHeight = CHART_CONTENT_MIN_HEIGHT; + const radius = Math.min(chartWidth, canvasHeight) / 2; const innerRadius = radius * VictoryTheme.pie.innerRadiusRatio; - const pieGeometry = {radius, innerRadius, centerX: canvasWidth / 2, centerY: canvasHeight / 2}; + const pieGeometry = {radius, innerRadius, centerX: chartWidth / 2, centerY: canvasHeight / 2}; // Slices are sorted by absolute value (largest first) for color assignment, // so slice indices don't match the original data array. We map back via @@ -171,30 +171,16 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice ); }; - if (isLoading) { - return ( - - - - ); - } - - if (data.length === 0) { - return null; - } - return ( <> - + {processedSlices.length > 0 && ( @@ -249,5 +235,28 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice ); } +function PieChartContent({isLoading = false, chartWidth, ...canvasProps}: PieChartContentProps) { + const styles = useThemeStyles(); + + if (!isLoading && canvasProps.data.length === 0) { + return null; + } + + if (isLoading || chartWidth === null) { + return ( + + + + ); + } + + return ( + + ); +} + export default PieChartContent; export type {PieChartProps}; diff --git a/src/components/Charts/PieChart/index.native.tsx b/src/components/Charts/PieChart/index.native.tsx index 961f5bdfb82c..97d224a27cbf 100644 --- a/src/components/Charts/PieChart/index.native.tsx +++ b/src/components/Charts/PieChart/index.native.tsx @@ -1,3 +1,5 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; + import React from 'react'; import type {PieChartProps} from './PieChartContent'; @@ -5,7 +7,16 @@ import type {PieChartProps} from './PieChartContent'; import PieChartContent from './PieChartContent'; function PieChart(props: PieChartProps) { - return ; + return ( + + {(chartWidth) => ( + + )} + + ); } PieChart.displayName = 'PieChart'; diff --git a/src/components/Charts/PieChart/index.tsx b/src/components/Charts/PieChart/index.tsx index 66999fee714f..1441e2cdc847 100644 --- a/src/components/Charts/PieChart/index.tsx +++ b/src/components/Charts/PieChart/index.tsx @@ -1,3 +1,4 @@ +import ChartWidthBox from '@components/Charts/components/ChartWidthBox'; import SkiaWebChart from '@components/Charts/SkiaWebChart'; import React from 'react'; @@ -8,10 +9,14 @@ const getPieChartContent = () => import('./PieChartContent'); function PieChart(props: PieChartProps) { return ( - + + {(chartWidth) => ( + + )} + ); } diff --git a/src/components/Charts/components/ChartWidthBox.tsx b/src/components/Charts/components/ChartWidthBox.tsx new file mode 100644 index 000000000000..274aecd049f1 --- /dev/null +++ b/src/components/Charts/components/ChartWidthBox.tsx @@ -0,0 +1,28 @@ +import useContainerWidth from '@hooks/useContainerWidth'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; + +import {View} from 'react-native'; + +type ChartWidthBoxProps = { + /** Receives the card's width, or null until the card has been measured. */ + children: (chartWidth: number | null) => ReactNode; +}; + +/** On web the chart component mounts only once the chart engine has downloaded, so measuring here keeps the width across that mount. */ +function ChartWidthBox({children}: ChartWidthBoxProps) { + const styles = useThemeStyles(); + const {onLayout, containerWidth} = useContainerWidth(); + + return ( + + {children(containerWidth > 0 ? containerWidth : null)} + + ); +} + +export default ChartWidthBox; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 5fa23151604b..e7b098c98e74 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -9,3 +9,5 @@ export {default as useChartLabelFormats} from './useChartLabelFormats'; export {default as useDynamicYDomain} from './useDynamicYDomain'; export {default as useTooltipData} from './useTooltipData'; export {default as useLabelHitTesting} from './useLabelHitTesting'; +export {default as useReadyCartesianCanvas} from './useReadyCartesianCanvas'; +export type {CartesianCanvasInputs} from './useReadyCartesianCanvas'; diff --git a/src/components/Charts/hooks/useReadyCartesianCanvas.ts b/src/components/Charts/hooks/useReadyCartesianCanvas.ts new file mode 100644 index 000000000000..13e7d3288bfa --- /dev/null +++ b/src/components/Charts/hooks/useReadyCartesianCanvas.ts @@ -0,0 +1,21 @@ +import {useChartFontManager} from '@components/Charts/context/ChartFontsContext'; + +import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; + +type CartesianCanvasInputs = { + chartWidth: number; + fontManager: SkTypefaceFontProvider; +}; + +function useReadyCartesianCanvas(isLoading: boolean, chartWidth: number | null): CartesianCanvasInputs | null { + const fontManager = useChartFontManager(); + + if (isLoading || !fontManager || chartWidth === null) { + return null; + } + + return {chartWidth, fontManager}; +} + +export default useReadyCartesianCanvas; +export type {CartesianCanvasInputs}; diff --git a/src/components/Charts/utils/index.ts b/src/components/Charts/utils/index.ts index eb3592f32242..64af6dbac027 100644 --- a/src/components/Charts/utils/index.ts +++ b/src/components/Charts/utils/index.ts @@ -445,6 +445,16 @@ function getBarLayout(plotWidth: number, barCount: number): {barWidth: number; g return {barWidth, gap, edgeSpace, xDomain: [-edgeSpaceInXUnits, barCount - 1 + edgeSpaceInXUnits]}; } +/** + * Horizontal plot bounds of a cartesian chart with its y axis on the right, mirroring victory-native's layout, + * so labels can be laid out from the width before the chart mounts. + */ +function getCartesianPlotBounds(chartWidth: number, paddingRight: number): {left: number; right: number; width: number} { + const left = VictoryTheme.axis.padding.left; + const right = Math.max(left, chartWidth - paddingRight - VictoryTheme.axis.labelGap); + return {left, right, width: right - left}; +} + /** * Domain padding that leaves `edgeSpace` px between the plot edges and the first and last points. * victory-native fits the padded domain back into the plot width, so the space on screen is smaller than the padding. @@ -503,6 +513,7 @@ export { isCursorOverChartLabel, getNiceYAxisTicks, getYAxisLabelWidth, + getCartesianPlotBounds, getBarLayout, getDomainPaddingForEdgeSpace, }; diff --git a/tests/unit/components/Charts/chartWidthHandoff.test.tsx b/tests/unit/components/Charts/chartWidthHandoff.test.tsx new file mode 100644 index 000000000000..cd4bc4c76dd0 --- /dev/null +++ b/tests/unit/components/Charts/chartWidthHandoff.test.tsx @@ -0,0 +1,94 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import ActivityIndicator from '@components/ActivityIndicator'; +import BarChart from '@components/Charts/BarChart'; +import LineChart from '@components/Charts/LineChart'; +import PieChart from '@components/Charts/PieChart'; +import type {ChartDataPoint} from '@components/Charts/types'; +import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme'; + +import React from 'react'; +import {PolarChart} from 'victory-native'; + +const CONTAINER_WIDTH = 320; + +const data: ChartDataPoint[] = [ + {label: 'Amazon', total: 120}, + {label: 'Uber', total: 80}, +]; + +function getMeasuredBox() { + let node = screen.UNSAFE_getByType(ActivityIndicator).parent; + + while (node) { + if (typeof node.props.onLayout === 'function') { + return node; + } + node = node.parent; + } + + throw new Error('the loading spinner has no ancestor reporting its layout'); +} + +describe('chart width handoff', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + ['bar', BarChart], + ['line', LineChart], + ])('should measure the %s chart width while the chart is still loading', (_name, Chart) => { + // Given a chart whose data has not arrived yet + const loadingChart = ( + + ); + + // When it renders its loading state + render(loadingChart); + + // Then the spinner already sits inside the box that measures the width, + // so the width is known by the time the data arrives and the chart can be sized in the pass it mounts in + expect(getMeasuredBox()).toBeTruthy(); + }); + + it('should hand the pie chart the width measured while it was loading', () => { + // Given a loading pie chart whose box has been measured + const {rerender} = render( + , + ); + fireEvent(getMeasuredBox(), 'layout', {nativeEvent: {layout: {width: CONTAINER_WIDTH, height: CHART_CONTENT_MIN_HEIGHT}}}); + + // When the data arrives + rerender( + , + ); + + // Then the chart mounts with that size instead of measuring itself, which would cost it a render with no content + expect(PolarChart).toHaveBeenCalledWith(expect.objectContaining({explicitSize: {width: CONTAINER_WIDTH, height: CHART_CONTENT_MIN_HEIGHT}}), undefined); + }); + + it('should keep the pie chart under the spinner until a width has been measured', () => { + // Given a pie chart whose box has not reported a layout yet + // When it renders with its data + render( + , + ); + + // Then the spinner stays and the chart does not mount, because it can only be sized once the width is known + expect(PolarChart).not.toHaveBeenCalled(); + expect(screen.UNSAFE_queryByType(ActivityIndicator)).not.toBeNull(); + }); +}); diff --git a/tests/unit/components/Charts/utils.test.ts b/tests/unit/components/Charts/utils.test.ts index f11eb3b8e99b..4e4af99d3133 100644 --- a/tests/unit/components/Charts/utils.test.ts +++ b/tests/unit/components/Charts/utils.test.ts @@ -10,6 +10,7 @@ import { getAdditionalOffset, getDomainPaddingForEdgeSpace, getNiceYAxisTicks, + getCartesianPlotBounds, isAngleInSlice, isCursorInSkewedLabel, isCursorOverChartLabel, @@ -751,6 +752,30 @@ describe('getBarLayout', () => { }); }); +describe('getCartesianPlotBounds', () => { + const LABEL_GAP = VictoryTheme.axis.labelGap; + const PADDING_LEFT = VictoryTheme.axis.padding.left; + + it('reserves the right gutter for labels and the left base padding', () => { + // Given a 300px container with a 30px right gutter + // When computing the plot bounds + // Then the plot spans from the left padding to the label gap before the gutter, where victory-native puts it, + // so labels laid out before the chart mounts line up with the plot it draws + expect(getCartesianPlotBounds(300, 30)).toEqual({left: PADDING_LEFT, right: 300 - 30 - LABEL_GAP, width: 300 - 30 - LABEL_GAP - PADDING_LEFT}); + }); + + it('clamps to a zero-width plot when the container is too small for the gutters', () => { + // Given a container narrower than the right gutter itself + // When computing the plot bounds + // Then the plot collapses to zero width at the left padding, because a negative width would break the bar and + // label layouts that are sized from it + const bounds = getCartesianPlotBounds(10, 30); + expect(bounds.left).toBe(PADDING_LEFT); + expect(bounds.right).toBe(PADDING_LEFT); + expect(bounds.width).toBe(0); + }); +}); + describe('getDomainPaddingForEdgeSpace', () => { it('returns the padding victory-native shrinks back to the requested edge space', () => { // Given a 200px plot and the space wanted before the first and after the last point From 654e67a28494ed4d76e72e5667fa9a2b618e2203 Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Sun, 11 Oct 2026 21:14:49 +0000 Subject: [PATCH 438/781] Mark the report as read after scrolling to the user's own comment Without this, the New divider stayed visible and the unread marker turned the New messages pill back on after the scroll. Co-authored-by: Abdelrahman Khattab --- src/hooks/useReportActionsScroll.ts | 1 + .../useReportActionsNewActionLiveTail.ts | 10 +++++++ .../useReportActionsNewActionLiveTailTest.ts | 29 ++++++++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index cc9d6bc988e0..d8942cb0dac9 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -242,6 +242,7 @@ function useReportActionsScroll({ reportScrollManager, setIsFloatingMessageCounterVisible, setActionIdToHighlight, + markNewestActionAsRead, unreadMarkerReportActionID, hasNewerActions, linkedReportActionID, diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index 10efdd623565..86a76484bbe0 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -44,6 +44,7 @@ type UseReportActionsNewActionLiveTailParams = { reportScrollManager: ReportScrollManager; setIsFloatingMessageCounterVisible: (visible: boolean) => void; setActionIdToHighlight: (actionID: string) => void; + markNewestActionAsRead: () => void; unreadMarkerReportActionID: string | null; hasNewerActions: boolean; linkedReportActionID: string | undefined; @@ -75,6 +76,7 @@ function useReportActionsNewActionLiveTail({ reportScrollManager, setIsFloatingMessageCounterVisible, setActionIdToHighlight, + markNewestActionAsRead, unreadMarkerReportActionID, hasNewerActions, linkedReportActionID, @@ -93,6 +95,12 @@ function useReportActionsNewActionLiveTail({ const liveTailJumpRef = useRef<{stage: LiveTailJumpStage}>({stage: 'idle'}); const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); + // Scrolling to the user's own comment must also mark the report as read. Otherwise the "New" divider stays, and the + // unread marker it leaves behind turns the "New messages" pill back on. An effect event keeps it out of effect deps. + const markAsReadAfterScroll = useEffectEvent(() => { + markNewestActionAsRead(); + }); + const scrollToBottomForCurrentUserAction = useEffectEvent((isFromCurrentUser: boolean, action?: OnyxTypes.ReportAction) => { TransitionTracker.runAfterTransitions({ callback: () => { @@ -150,6 +158,7 @@ function useReportActionsNewActionLiveTail({ } else { setIsFloatingMessageCounterVisible(false); reportScrollManager.scrollToBottom(); + markAsReadAfterScroll(); } setIsScrollToBottomEnabled(true); @@ -207,6 +216,7 @@ function useReportActionsNewActionLiveTail({ liveTailJumpRef.current = {stage: 'await_prune'}; setIsFloatingMessageCounterVisible(false); + markAsReadAfterScroll(); // Defer so this effect does not synchronously chain a second render from setState (eslint react-hooks/set-state-in-effect). queueMicrotask(() => { setIsScrollToBottomEnabled(true); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index 3b2f63e6df8b..faeaff36742e 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -99,6 +99,7 @@ function buildParams(overrides: Partial = {}): HookParams { reportScrollManager, setIsFloatingMessageCounterVisible: jest.fn(), setActionIdToHighlight: jest.fn(), + markNewestActionAsRead: jest.fn(), unreadMarkerReportActionID: null, hasNewerActions: true, linkedReportActionID: undefined, @@ -213,8 +214,9 @@ describe('useReportActionsNewActionLiveTail', () => { it('scrolls straight to the bottom when an unread marker is set but the newest actions are already loaded', () => { // Given a report scrolled up with an unread marker from another user's comment, and the newest actions already loaded const setIsFloatingMessageCounterVisible = jest.fn(); + const markNewestActionAsRead = jest.fn(); const {result} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { - initialProps: buildParams({unreadMarkerReportActionID: '2', hasNewerActions: false, hasNewestReportAction: true, setIsFloatingMessageCounterVisible}), + initialProps: buildParams({unreadMarkerReportActionID: '2', hasNewerActions: false, hasNewestReportAction: true, setIsFloatingMessageCounterVisible, markNewestActionAsRead}), }); // When the current user sends a comment @@ -227,6 +229,31 @@ describe('useReportActionsNewActionLiveTail', () => { expect(setIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); expect(result.current.isScrollToBottomEnabled).toBe(true); + + // And the report is marked as read, so the "New" divider clears and the pill doesn't come back + expect(markNewestActionAsRead).toHaveBeenCalledTimes(1); + }); + + it('marks the report as read once openReport has loaded the newest actions after the user sends a comment', () => { + // Given a report whose newest actions are not loaded yet + const markNewestActionAsRead = jest.fn(); + const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({markNewestActionAsRead})}); + + // When the current user sends a comment, which starts the live-tail jump through openReport + act(() => { + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + }); + + // Then nothing is marked as read while the newest actions are still loading + expect(mockOpenReport).toHaveBeenCalledTimes(1); + expect(markNewestActionAsRead).not.toHaveBeenCalled(); + + // When openReport finishes and the newest actions arrive + rerender(buildParams({markNewestActionAsRead, prevIsLoadingInitialReportActions: true, reportLoadingState: {isLoadingInitialReportActions: false}})); + rerender(buildParams({markNewestActionAsRead, prevIsLoadingInitialReportActions: false, reportLoadingState: {isLoadingInitialReportActions: false}, hasNewestReportAction: true})); + + // Then the report is marked as read once, alongside hiding the pill + expect(markNewestActionAsRead).toHaveBeenCalledTimes(1); }); it('still loads the live tail when an unread marker is set and the newest actions are not loaded', () => { From 5bf4aafe36b0fdabd5f3cdcd1b5037270a6832f5 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Sun, 11 Oct 2026 22:01:18 +0000 Subject: [PATCH 439/781] Regenerate GitHub Actions compiled files after clean install --- .../javascript/authorChecklist/index.js | 901 ++++++++++---- .../javascript/awaitStagingDeploys/index.js | 901 ++++++++++---- .../actions/javascript/bumpVersion/index.js | 901 ++++++++++---- .../javascript/checkAndroidStatus/index.js | 903 ++++++++++---- .../javascript/checkDeployBlockers/index.js | 901 ++++++++++---- .../javascript/checkSVGCompression/index.js | 901 ++++++++++---- .../javascript/failureNotifier/index.js | 901 ++++++++++---- .../javascript/formatCodeCovComment/index.js | 901 ++++++++++---- .../generateHelpPreviewComment/index.js | 901 ++++++++++---- .../getAndroidRolloutPercentage/index.js | 903 ++++++++++---- .../javascript/getArtifactInfo/index.js | 901 ++++++++++---- .../getDeployPullRequestList/index.js | 901 ++++++++++---- .../javascript/getPreviousVersion/index.js | 901 ++++++++++---- .../javascript/getPullRequestDetails/index.js | 901 ++++++++++---- .../getPullRequestIncrementalChanges/index.js | 901 ++++++++++---- .../isAuthorizedContributor/index.js | 901 ++++++++++---- .../isDeployChecklistLocked/index.js | 1054 +++++++++++++---- .../markPullRequestsAsDeployed/index.js | 901 ++++++++++---- .../javascript/postOrReplaceComment/index.js | 901 ++++++++++---- .../javascript/proposalPoliceComment/index.js | 1054 +++++++++++++---- .../reopenIssueWithComment/index.js | 901 ++++++++++---- .../javascript/reviewerChecklist/index.js | 901 ++++++++++---- .../validateReassureOutput/index.js | 901 ++++++++++---- .../javascript/verifySignedCommits/index.js | 901 ++++++++++---- .../verifyStagingBuildsSucceeded/index.js | 901 ++++++++++---- 25 files changed, 17075 insertions(+), 5760 deletions(-) diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index f76542955ddf..b06a43adc37e 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index 906a5fe20da8..00e2f735fcb4 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js index 479e50a322da..c2258fb0a82d 100644 --- a/.github/actions/javascript/bumpVersion/index.js +++ b/.github/actions/javascript/bumpVersion/index.js @@ -2312,10 +2312,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -2382,7 +2438,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -2394,6 +2454,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request.host !== null) { @@ -2503,13 +2566,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -6116,6 +6187,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -6162,6 +6234,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -6324,24 +6399,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -6361,6 +6467,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6404,7 +6514,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -6429,9 +6539,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -6440,6 +6551,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6565,6 +6680,7 @@ var require_client_h1 = __commonJS({ } request.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6608,12 +6724,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6628,7 +6751,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6636,9 +6762,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6687,7 +6814,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request) { @@ -6705,6 +6832,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6717,6 +6861,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6747,8 +6914,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6769,11 +6944,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request.aborted || request.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request.aborted) { return; } - util.errorRequest(client, request, err || new RequestAbortedError()); + if (request.completed) { + if (request.upgrade || request.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } + return; + } + util.errorRequest(client, request, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -7129,6 +7311,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -7189,6 +7372,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -7352,16 +7539,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request.aborted || request.completed) { + const abort = (error2) => { + if (request.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request, err); + if (request.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -7376,18 +7569,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request.onUpgradeError(error2); + }; + const onReady = () => { + try { request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7948,9 +8165,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8456,8 +8674,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8628,7 +8846,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8649,6 +8866,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8848,7 +9066,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8858,6 +9075,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -9298,6 +9516,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -9351,6 +9587,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -9360,6 +9597,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -9421,7 +9669,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9470,9 +9720,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9480,6 +9742,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9487,6 +9750,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9506,6 +9774,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9533,7 +9802,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -16332,14 +16601,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -16422,7 +16725,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16552,18 +16859,14 @@ var require_parse2 = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -17385,7 +17688,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17491,31 +17794,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17534,20 +17832,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17557,19 +17847,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17600,8 +17888,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17609,18 +17903,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17635,6 +17932,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17694,6 +17998,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17714,6 +18021,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17727,6 +18037,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17737,32 +18050,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17805,6 +18132,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -18016,8 +18363,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -18061,9 +18406,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -18247,7 +18589,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -18322,19 +18670,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -18413,6 +18748,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -18432,10 +18801,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18466,63 +18838,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18531,17 +18870,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18557,43 +18895,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18618,12 +18955,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/checkAndroidStatus/index.js b/.github/actions/javascript/checkAndroidStatus/index.js index cf13138d08ce..de9e0e53ebc4 100644 --- a/.github/actions/javascript/checkAndroidStatus/index.js +++ b/.github/actions/javascript/checkAndroidStatus/index.js @@ -1875,10 +1875,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1945,7 +2001,11 @@ var require_request = __commonJS({ } else if (typeof val[i2] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i2]}`); + const str = `${val[i2]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1957,6 +2017,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2066,13 +2129,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5679,6 +5750,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5725,6 +5797,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5887,24 +5962,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5924,6 +6030,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5967,7 +6077,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5992,9 +6102,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -6003,6 +6114,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6128,6 +6243,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6171,12 +6287,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6191,7 +6314,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6199,9 +6325,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6250,7 +6377,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6268,6 +6395,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6280,6 +6424,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6310,8 +6477,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6332,11 +6507,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6692,6 +6874,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline: pipeline2 } = __require("node:stream"); var util = require_util(); var { @@ -6752,6 +6935,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6915,16 +7102,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6939,18 +7132,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7511,9 +7728,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8019,8 +8237,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8191,7 +8409,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8212,6 +8429,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8411,7 +8629,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8421,6 +8638,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8861,6 +9079,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8914,6 +9150,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8923,6 +9160,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8984,7 +9232,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9033,9 +9283,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9043,6 +9305,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9050,6 +9313,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9069,6 +9337,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9096,7 +9365,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15895,14 +16164,48 @@ var require_util6 = __commonJS({ for (let i2 = 0; i2 < path.length; ++i2) { const code = path.charCodeAt(i2); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i2 = 0; i2 < domain.length; ++i2) { + const code = domain.charCodeAt(i2); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i2 - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15985,7 +16288,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16115,18 +16422,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16948,7 +17251,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17054,31 +17357,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17097,20 +17395,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17120,19 +17410,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17163,8 +17451,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17172,18 +17466,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17198,6 +17495,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17257,6 +17561,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17277,6 +17584,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17290,6 +17600,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower2; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17300,32 +17613,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17368,6 +17695,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17579,8 +17926,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17624,9 +17969,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17810,7 +18152,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17885,19 +18233,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17976,6 +18311,40 @@ var require_eventsource_stream = __commonJS({ var CR2 = 13; var COLON2 = 58; var SPACE2 = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] < 48 || buffer[i2] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i2 = 0; i2 < length; i2++) { + if (line[i2] !== field[i2]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17995,10 +18364,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18029,63 +18401,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF2) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF2) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18094,17 +18433,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18120,43 +18458,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE2) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18181,12 +18518,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i2 = this.lineChunkIndex; i2 <= this.chunkIndex; i2++) { + const chunk = this.chunks[i2]; + const start = i2 === this.lineChunkIndex ? this.linePos : 0; + const end = i2 === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/checkDeployBlockers/index.js b/.github/actions/javascript/checkDeployBlockers/index.js index 2ca391c52c00..c59c854097c1 100644 --- a/.github/actions/javascript/checkDeployBlockers/index.js +++ b/.github/actions/javascript/checkDeployBlockers/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/checkSVGCompression/index.js b/.github/actions/javascript/checkSVGCompression/index.js index 1bd2d9dfd701..cad1560febba 100644 --- a/.github/actions/javascript/checkSVGCompression/index.js +++ b/.github/actions/javascript/checkSVGCompression/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/failureNotifier/index.js b/.github/actions/javascript/failureNotifier/index.js index 7eedc830062e..208e6286e656 100644 --- a/.github/actions/javascript/failureNotifier/index.js +++ b/.github/actions/javascript/failureNotifier/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/formatCodeCovComment/index.js b/.github/actions/javascript/formatCodeCovComment/index.js index 133b8de4b0d2..9aac21e43b56 100644 --- a/.github/actions/javascript/formatCodeCovComment/index.js +++ b/.github/actions/javascript/formatCodeCovComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/generateHelpPreviewComment/index.js b/.github/actions/javascript/generateHelpPreviewComment/index.js index 4fffa145a897..222ea227535c 100644 --- a/.github/actions/javascript/generateHelpPreviewComment/index.js +++ b/.github/actions/javascript/generateHelpPreviewComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error) { + util.errorRequest(client, request2, error); + util.destroy(socket, error); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error || new RequestAbortedError()); + } + return; + } + error = error || new RequestAbortedError(); + util.errorRequest(client, request2, error); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error); } - util.destroy(body, err); + util.destroy(body, error); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error) { + stream.off("response", onResponse); + abort(error); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error, data) => { - if (error) { - failWebsocketConnection(this.ws, error.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error, data) => { + if (error) { + const code = error instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getAndroidRolloutPercentage/index.js b/.github/actions/javascript/getAndroidRolloutPercentage/index.js index e970b3649819..1abd092e27ea 100644 --- a/.github/actions/javascript/getAndroidRolloutPercentage/index.js +++ b/.github/actions/javascript/getAndroidRolloutPercentage/index.js @@ -1875,10 +1875,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1945,7 +2001,11 @@ var require_request = __commonJS({ } else if (typeof val[i2] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i2]}`); + const str = `${val[i2]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1957,6 +2017,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request.host !== null) { @@ -2066,13 +2129,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5679,6 +5750,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5725,6 +5797,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5887,24 +5962,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5924,6 +6030,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -5967,7 +6077,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5992,9 +6102,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request.onUpgrade(statusCode, headers, socket, statusText); + } catch (error) { + util.errorRequest(client, request, error); + util.destroy(socket, error); } client[kResume](); } @@ -6003,6 +6114,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6128,6 +6243,7 @@ var require_client_h1 = __commonJS({ } request.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6171,12 +6287,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6191,7 +6314,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6199,9 +6325,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6250,7 +6377,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request) { @@ -6268,6 +6395,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6280,6 +6424,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6310,8 +6477,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6332,11 +6507,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request.aborted || request.completed) { + clearIdleSocketValidation(socket); + const abort = (error) => { + if (request.aborted) { + return; + } + if (request.completed) { + if (request.upgrade || request.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request, err || new RequestAbortedError()); + util.errorRequest(client, request, error || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6692,6 +6874,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline: pipeline2 } = __require("node:stream"); var util = require_util(); var { @@ -6752,6 +6935,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6915,16 +7102,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request.aborted || request.completed) { + const abort = (error) => { + if (request.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request, err); + if (request.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error || new RequestAbortedError()); + } + return; + } + error = error || new RequestAbortedError(); + util.errorRequest(client, request, error); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error); } - util.destroy(body, err); + util.destroy(body, error); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6939,18 +7132,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request.onUpgradeError(error); + }; + const onReady = () => { + try { request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error) { + stream.off("response", onResponse); + abort(error); + return; + } + if (request.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7511,9 +7728,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8019,8 +8237,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8191,7 +8409,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8212,6 +8429,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8411,7 +8629,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8421,6 +8638,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8861,6 +9079,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8914,6 +9150,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8923,6 +9160,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8984,7 +9232,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9033,9 +9283,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9043,6 +9305,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9050,6 +9313,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9069,6 +9337,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9096,7 +9365,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15895,14 +16164,48 @@ var require_util6 = __commonJS({ for (let i2 = 0; i2 < path.length; ++i2) { const code = path.charCodeAt(i2); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i2 = 0; i2 < domain.length; ++i2) { + const code = domain.charCodeAt(i2); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i2 - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15985,7 +16288,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16115,18 +16422,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; - } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16948,7 +17251,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17054,31 +17357,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17097,20 +17395,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17120,19 +17410,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17163,8 +17451,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17172,18 +17466,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17198,6 +17495,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17257,6 +17561,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17277,6 +17584,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17290,6 +17600,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower2; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17300,32 +17613,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error, data) => { - if (error) { - failWebsocketConnection(this.ws, error.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error, data) => { + if (error) { + const code = error instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17368,6 +17695,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17579,8 +17926,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17624,9 +17969,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17810,7 +18152,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17885,19 +18233,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17976,6 +18311,40 @@ var require_eventsource_stream = __commonJS({ var CR2 = 13; var COLON2 = 58; var SPACE2 = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] < 48 || buffer[i2] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i2 = start; i2 < buffer.length; i2++) { + if (buffer[i2] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i2 = 0; i2 < length; i2++) { + if (line[i2] !== field[i2]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17995,10 +18364,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18029,63 +18401,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF2) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF2) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18094,17 +18433,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF2 || this.buffer[this.pos] === CR2) { - if (this.buffer[this.pos] === CR2) { + if (byte === LF2 || byte === CR2) { + if (byte === CR2) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18120,43 +18458,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE2) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18181,12 +18518,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i2 = this.lineChunkIndex; i2 <= this.chunkIndex; i2++) { + const chunk = this.chunks[i2]; + const start = i2 === this.lineChunkIndex ? this.linePos : 0; + const end = i2 === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getArtifactInfo/index.js b/.github/actions/javascript/getArtifactInfo/index.js index 1d7315a4ae65..b511ee174521 100644 --- a/.github/actions/javascript/getArtifactInfo/index.js +++ b/.github/actions/javascript/getArtifactInfo/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index 86ebf2b747d2..c6a083ec32cf 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getPreviousVersion/index.js b/.github/actions/javascript/getPreviousVersion/index.js index 0343158e5a02..dc48a205f79b 100644 --- a/.github/actions/javascript/getPreviousVersion/index.js +++ b/.github/actions/javascript/getPreviousVersion/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js index 78b36a004849..b7443787fc9b 100644 --- a/.github/actions/javascript/getPullRequestDetails/index.js +++ b/.github/actions/javascript/getPullRequestDetails/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js index 21c54003dde2..e868a069ae7e 100644 --- a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js +++ b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error3) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error3 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error3) { + util.errorRequest(client, request2, error3); + util.destroy(socket, error3); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error3) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error3 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error3) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error3 || new RequestAbortedError()); + } + return; + } + error3 = error3 || new RequestAbortedError(); + util.errorRequest(client, request2, error3); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error3); } - util.destroy(body, err); + util.destroy(body, error3); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error3) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error3); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error3) { + stream.off("response", onResponse); + abort(error3); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error3, data) => { - if (error3) { - failWebsocketConnection(this.ws, error3.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error3, data) => { + if (error3) { + const code = error3 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error3.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/isAuthorizedContributor/index.js b/.github/actions/javascript/isAuthorizedContributor/index.js index fea991ad887f..d43a19fced4c 100644 --- a/.github/actions/javascript/isAuthorizedContributor/index.js +++ b/.github/actions/javascript/isAuthorizedContributor/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index b1e68e9965bf..9321db05e6d7 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -23112,6 +23112,7 @@ var require_ExpensiMark = __commonJS({ var Constants = __importStar(require_CONST()); var UrlPatterns = __importStar(require_Url()); var Logger_1 = __importDefault(require_Logger()); + var tlds_1 = __importDefault(require_tlds()); var Utils = __importStar(require_utils()); var EXTRAS_DEFAULT = {}; var ASCII_DIGIT_START = "0".charCodeAt(0); @@ -23124,6 +23125,9 @@ var require_ExpensiMark = __commonJS({ var NON_BREAKING_SPACE_CODE = 160; var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var URL_TLD_LIST = tlds_1.default.toLowerCase().split("|"); + var URL_TLDS = new Set(URL_TLD_LIST); + var MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length)); var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); @@ -23169,6 +23173,9 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function canUseCandidateScanning(text, shouldEscapeText) { + return shouldEscapeText || !text.includes("<") && !text.includes(">"); + } function isAsciiAlphaNumeric(character) { if (!character) { return false; @@ -23240,15 +23247,59 @@ var require_ExpensiMark = __commonJS({ } return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); } - function findHostnameEnd(text, hostnameStart, dotPosition) { - let hostnameEnd = dotPosition + 1; - while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { - hostnameEnd++; + function isValidHostnameLabel(text, start, end) { + if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { + return false; } - if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { - return void 0; + for (let index = start + 1; index < end - 1; index++) { + if (!isAsciiAlphaNumeric(text[index]) && text[index] !== "-") { + return false; + } + } + return true; + } + function findHostnameStart(text, dotPosition) { + let hostnameStart = dotPosition; + let labelEnd = dotPosition; + while (labelEnd > 0) { + let rawLabelStart = labelEnd - 1; + while (rawLabelStart >= 0 && text[rawLabelStart] !== "." && isHostnameCharacter(text[rawLabelStart])) { + rawLabelStart--; + } + rawLabelStart++; + let labelStart = rawLabelStart; + while (labelStart < labelEnd && text[labelStart] === "-") { + labelStart++; + } + if (!isValidHostnameLabel(text, labelStart, labelEnd)) { + break; + } + hostnameStart = labelStart; + if (labelStart !== rawLabelStart) { + break; + } + const separatorPosition = labelStart - 1; + if (separatorPosition < 0 || text[separatorPosition] !== ".") { + break; + } + labelEnd = separatorPosition; } - return hostnameEnd; + return hostnameStart === dotPosition ? void 0 : hostnameStart; + } + function findKnownTldEnd(text, dotPosition) { + const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH); + for (let end = dotPosition + 2; end <= maximumEnd; end++) { + const currentCharacter = text[end - 1]; + if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== "-") { + break; + } + const nextCharacter = text[end]; + const hasValidBoundary = !nextCharacter || nextCharacter === ":" || nextCharacter === "_" || !isWordCharacter(nextCharacter); + if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) { + return end; + } + } + return void 0; } function extendUrlCandidateBoundaries(text, start, end) { let candidateStart = start; @@ -23261,24 +23312,65 @@ var require_ExpensiMark = __commonJS({ } return { start: candidateStart, end: candidateEnd }; } + function startsWithIgnoreCase(text, expected, position) { + return text.slice(position, position + expected.length).toLowerCase() === expected; + } + function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) { + var _a; + if (candidates.length === 0 || !text.includes("<") && !text.includes(">")) { + return candidates; + } + const validCandidates = []; + let candidateIndex = candidates.length - 1; + let nextLessThan = text.length; + let nextGreaterThan = text.length; + let nextOpeningAnchor = text.length; + let nextClosingAnchor = text.length; + for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) { + if (text[index] === "<") { + nextLessThan = index; + if (((_a = text[index + 1]) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === "a") { + nextOpeningAnchor = index; + } else if (startsWithIgnoreCase(text, "", index)) { + nextClosingAnchor = index; + } + } else if (text[index] === ">") { + nextGreaterThan = index; + } + while (candidateIndex >= 0 && candidates[candidateIndex].end === index) { + const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith("", nextLessThan); + const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, "", nextLessThan) || startsWithIgnoreCase(text, "", nextLessThan); + const isBlockedByFollowingHtml = ( + // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`. + nextGreaterThan < nextLessThan || // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than ``. + firstHtmlBoundaryIsClosingTag || // Mirrors `((?:(?!`: reject `` unless another `` appears first. + nextClosingAnchor < nextOpeningAnchor || // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag. + firstTagIsProtectedClosingTag + ); + if (!isBlockedByFollowingHtml) { + validCandidates.push(candidates[candidateIndex]); + } + candidateIndex--; + } + } + return validCandidates.reverse(); + } function findUrlCandidates(text) { const candidates = []; const protectedTags = []; let index = 0; - let hostnameRunStart = 0; while (index < text.length) { if (text[index] === "<") { const nextIndex = updateProtectedTagStack(text, index, protectedTags); if (nextIndex === void 0) { - break; + index++; + continue; } index = nextIndex; - hostnameRunStart = index; continue; } if (protectedTags.length > 0) { index++; - hostnameRunStart = index; continue; } const matchedProtocol = getProtocolAt(text, index); @@ -23286,29 +23378,23 @@ var require_ExpensiMark = __commonJS({ const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); candidates.push(candidate2); index = candidate2.end; - hostnameRunStart = candidate2.end; - continue; - } - if (!isHostnameCharacter(text[index])) { - hostnameRunStart = index + 1; - index++; continue; } if (text[index] !== ".") { index++; continue; } - const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); - if (hostnameEnd === void 0) { + const tldEnd = findKnownTldEnd(text, index); + const hostnameStart = tldEnd === void 0 ? void 0 : findHostnameStart(text, index); + if (tldEnd === void 0 || hostnameStart === void 0) { index++; continue; } - const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); candidates.push(candidate); index = candidate.end; - hostnameRunStart = candidate.end; } - return candidates; + return filterUrlCandidatesBlockedByFollowingHtml(text, candidates); } function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { if (!text.includes(marker)) { @@ -23381,6 +23467,14 @@ var require_ExpensiMark = __commonJS({ output.push(text.slice(outputStart)); return output.join(""); } + function processMarkdownRule(regex2, marker, canOpen) { + return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { + if (canUseCandidateScanning(textToProcess, shouldEscapeText)) { + return replaceMarkdownCandidates(textToProcess, regex2, replacement, marker, canOpen); + } + return replaceTextWithExtras(textToProcess, regex2, EXTRAS_DEFAULT, replacement); + }; + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
> (|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -23799,9 +23893,9 @@ var require_ExpensiMark = __commonJS({ */ { name: "autolink", - process: (textToProcess, replacement) => { + process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText)); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -23870,7 +23964,8 @@ ${"
    ".repeat(i)}`, "\n"); name: "autoEmail", regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!|[^<>]*<\\/(?!em|h1|blockquote))`, "gim"), replacement: '$1$2', - rawInputReplacement: '$1$2' + rawInputReplacement: '$1$2', + shouldSkipProcessing: (textToCheck) => !textToCheck.includes("@") }, /** * This regex matches a short user mention in a string. @@ -23906,7 +24001,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), + process: processMarkdownRule(BOLD_MARKDOWN_REGEX, "*", canOpenBoldMarkdown), replacement: (_extras, match, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -23916,7 +24011,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), + process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match, g1) => g1.includes("") || containsNonPairTag(g1) ? match : `${g1}` }, { @@ -24285,7 +24380,7 @@ ${g2} } const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; if ("process" in rule) { - replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText); } else { replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement); } @@ -28352,10 +28447,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -28422,7 +28573,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -28434,6 +28589,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -28543,13 +28701,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -32156,6 +32322,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -32202,6 +32369,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -32364,24 +32534,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -32401,6 +32602,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -32444,7 +32649,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -32469,9 +32674,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -32480,6 +32686,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -32605,6 +32815,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -32648,12 +32859,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -32668,7 +32886,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -32676,9 +32897,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -32727,7 +32949,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -32745,6 +32967,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -32757,6 +32996,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -32787,8 +33049,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -32809,11 +33079,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } + return; + } + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -33169,6 +33446,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -33229,6 +33507,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -33392,16 +33674,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -33416,18 +33704,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -33988,9 +34300,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -34496,8 +34809,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -34668,7 +34981,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -34689,6 +35001,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -34888,7 +35201,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -34898,6 +35210,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -35338,6 +35651,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -35391,6 +35722,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -35400,6 +35732,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -35461,7 +35804,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -35510,9 +35855,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -35520,6 +35877,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -35527,6 +35885,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -35546,6 +35909,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -35573,7 +35937,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -42372,14 +42736,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -42462,7 +42860,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -42592,18 +42994,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; - } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -43425,7 +43823,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -43531,31 +43929,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -43574,20 +43967,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -43597,19 +43982,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -43640,8 +44023,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -43649,18 +44038,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -43675,6 +44067,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -43734,6 +44133,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -43754,6 +44156,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -43767,6 +44172,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -43777,32 +44185,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -43845,6 +44267,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -44056,8 +44498,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -44101,9 +44541,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -44287,7 +44724,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -44362,19 +44805,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -44453,6 +44883,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -44472,10 +44936,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -44506,63 +44973,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -44571,17 +45005,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -44597,43 +45030,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -44658,12 +45090,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 62a50ec22616..5eca45f374da 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/postOrReplaceComment/index.js b/.github/actions/javascript/postOrReplaceComment/index.js index fb9eb8df9e7b..195707dedc9f 100644 --- a/.github/actions/javascript/postOrReplaceComment/index.js +++ b/.github/actions/javascript/postOrReplaceComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 6c66aba05ee9..beb367dd3ebd 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str2 = `${val[i]}`; + if (!isValidHeaderValue(str2)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str2); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message2 = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message2 = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; - } - throw new HTTPParserError(message2, constants3.ERROR[ret], data.slice(offset)); + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); + } } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message2 = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message2 = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message2, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path2.length; ++i) { const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { @@ -43926,6 +44371,7 @@ var require_ExpensiMark = __commonJS({ var Constants = __importStar(require_CONST()); var UrlPatterns = __importStar(require_Url()); var Logger_1 = __importDefault(require_Logger()); + var tlds_1 = __importDefault(require_tlds()); var Utils = __importStar(require_utils2()); var EXTRAS_DEFAULT = {}; var ASCII_DIGIT_START = "0".charCodeAt(0); @@ -43938,6 +44384,9 @@ var require_ExpensiMark = __commonJS({ var NON_BREAKING_SPACE_CODE = 160; var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var URL_TLD_LIST = tlds_1.default.toLowerCase().split("|"); + var URL_TLDS = new Set(URL_TLD_LIST); + var MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length)); var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); @@ -43983,6 +44432,9 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function canUseCandidateScanning(text, shouldEscapeText) { + return shouldEscapeText || !text.includes("<") && !text.includes(">"); + } function isAsciiAlphaNumeric(character) { if (!character) { return false; @@ -44054,15 +44506,59 @@ var require_ExpensiMark = __commonJS({ } return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); } - function findHostnameEnd(text, hostnameStart, dotPosition) { - let hostnameEnd = dotPosition + 1; - while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { - hostnameEnd++; + function isValidHostnameLabel(text, start, end) { + if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { + return false; } - if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { - return void 0; + for (let index = start + 1; index < end - 1; index++) { + if (!isAsciiAlphaNumeric(text[index]) && text[index] !== "-") { + return false; + } + } + return true; + } + function findHostnameStart(text, dotPosition) { + let hostnameStart = dotPosition; + let labelEnd = dotPosition; + while (labelEnd > 0) { + let rawLabelStart = labelEnd - 1; + while (rawLabelStart >= 0 && text[rawLabelStart] !== "." && isHostnameCharacter(text[rawLabelStart])) { + rawLabelStart--; + } + rawLabelStart++; + let labelStart = rawLabelStart; + while (labelStart < labelEnd && text[labelStart] === "-") { + labelStart++; + } + if (!isValidHostnameLabel(text, labelStart, labelEnd)) { + break; + } + hostnameStart = labelStart; + if (labelStart !== rawLabelStart) { + break; + } + const separatorPosition = labelStart - 1; + if (separatorPosition < 0 || text[separatorPosition] !== ".") { + break; + } + labelEnd = separatorPosition; + } + return hostnameStart === dotPosition ? void 0 : hostnameStart; + } + function findKnownTldEnd(text, dotPosition) { + const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH); + for (let end = dotPosition + 2; end <= maximumEnd; end++) { + const currentCharacter = text[end - 1]; + if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== "-") { + break; + } + const nextCharacter = text[end]; + const hasValidBoundary = !nextCharacter || nextCharacter === ":" || nextCharacter === "_" || !isWordCharacter(nextCharacter); + if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) { + return end; + } } - return hostnameEnd; + return void 0; } function extendUrlCandidateBoundaries(text, start, end) { let candidateStart = start; @@ -44075,24 +44571,65 @@ var require_ExpensiMark = __commonJS({ } return { start: candidateStart, end: candidateEnd }; } + function startsWithIgnoreCase(text, expected, position) { + return text.slice(position, position + expected.length).toLowerCase() === expected; + } + function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) { + var _a3; + if (candidates.length === 0 || !text.includes("<") && !text.includes(">")) { + return candidates; + } + const validCandidates = []; + let candidateIndex = candidates.length - 1; + let nextLessThan = text.length; + let nextGreaterThan = text.length; + let nextOpeningAnchor = text.length; + let nextClosingAnchor = text.length; + for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) { + if (text[index] === "<") { + nextLessThan = index; + if (((_a3 = text[index + 1]) === null || _a3 === void 0 ? void 0 : _a3.toLowerCase()) === "a") { + nextOpeningAnchor = index; + } else if (startsWithIgnoreCase(text, "", index)) { + nextClosingAnchor = index; + } + } else if (text[index] === ">") { + nextGreaterThan = index; + } + while (candidateIndex >= 0 && candidates[candidateIndex].end === index) { + const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith("", nextLessThan); + const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, "", nextLessThan) || startsWithIgnoreCase(text, "", nextLessThan); + const isBlockedByFollowingHtml = ( + // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`. + nextGreaterThan < nextLessThan || // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than `
  • `. + firstHtmlBoundaryIsClosingTag || // Mirrors `((?:(?!`: reject `` unless another `` appears first. + nextClosingAnchor < nextOpeningAnchor || // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag. + firstTagIsProtectedClosingTag + ); + if (!isBlockedByFollowingHtml) { + validCandidates.push(candidates[candidateIndex]); + } + candidateIndex--; + } + } + return validCandidates.reverse(); + } function findUrlCandidates(text) { const candidates = []; const protectedTags = []; let index = 0; - let hostnameRunStart = 0; while (index < text.length) { if (text[index] === "<") { const nextIndex = updateProtectedTagStack(text, index, protectedTags); if (nextIndex === void 0) { - break; + index++; + continue; } index = nextIndex; - hostnameRunStart = index; continue; } if (protectedTags.length > 0) { index++; - hostnameRunStart = index; continue; } const matchedProtocol = getProtocolAt(text, index); @@ -44100,29 +44637,23 @@ var require_ExpensiMark = __commonJS({ const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); candidates.push(candidate2); index = candidate2.end; - hostnameRunStart = candidate2.end; - continue; - } - if (!isHostnameCharacter(text[index])) { - hostnameRunStart = index + 1; - index++; continue; } if (text[index] !== ".") { index++; continue; } - const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); - if (hostnameEnd === void 0) { + const tldEnd = findKnownTldEnd(text, index); + const hostnameStart = tldEnd === void 0 ? void 0 : findHostnameStart(text, index); + if (tldEnd === void 0 || hostnameStart === void 0) { index++; continue; } - const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); candidates.push(candidate); index = candidate.end; - hostnameRunStart = candidate.end; } - return candidates; + return filterUrlCandidatesBlockedByFollowingHtml(text, candidates); } function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { if (!text.includes(marker)) { @@ -44195,6 +44726,14 @@ var require_ExpensiMark = __commonJS({ output.push(text.slice(outputStart)); return output.join(""); } + function processMarkdownRule(regex2, marker, canOpen) { + return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { + if (canUseCandidateScanning(textToProcess, shouldEscapeText)) { + return replaceMarkdownCandidates(textToProcess, regex2, replacement, marker, canOpen); + } + return replaceTextWithExtras(textToProcess, regex2, EXTRAS_DEFAULT, replacement); + }; + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
    > (|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -44613,9 +45152,9 @@ var require_ExpensiMark = __commonJS({ */ { name: "autolink", - process: (textToProcess, replacement) => { + process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText)); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -44684,7 +45223,8 @@ ${"
    ".repeat(i)}`, "\n"); name: "autoEmail", regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!|[^<>]*<\\/(?!em|h1|blockquote))`, "gim"), replacement: '$1$2', - rawInputReplacement: '$1$2' + rawInputReplacement: '$1$2', + shouldSkipProcessing: (textToCheck) => !textToCheck.includes("@") }, /** * This regex matches a short user mention in a string. @@ -44720,7 +45260,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), + process: processMarkdownRule(BOLD_MARKDOWN_REGEX, "*", canOpenBoldMarkdown), replacement: (_extras, match2, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -44730,7 +45270,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), + process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match2, g1) => g1.includes("") || containsNonPairTag(g1) ? match2 : `${g1}` }, { @@ -45099,7 +45639,7 @@ ${g2} } const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; if ("process" in rule) { - replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText); } else { replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement); } diff --git a/.github/actions/javascript/reopenIssueWithComment/index.js b/.github/actions/javascript/reopenIssueWithComment/index.js index f237f20694fd..2fe118ee3e64 100644 --- a/.github/actions/javascript/reopenIssueWithComment/index.js +++ b/.github/actions/javascript/reopenIssueWithComment/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/reviewerChecklist/index.js b/.github/actions/javascript/reviewerChecklist/index.js index a9419acf5a2e..8e220a2cf59e 100644 --- a/.github/actions/javascript/reviewerChecklist/index.js +++ b/.github/actions/javascript/reviewerChecklist/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/validateReassureOutput/index.js b/.github/actions/javascript/validateReassureOutput/index.js index b26522496b9c..e511f56ffd08 100644 --- a/.github/actions/javascript/validateReassureOutput/index.js +++ b/.github/actions/javascript/validateReassureOutput/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request = client[kQueue][client[kRunningIdx]]; if (!request) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request.aborted || request.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request.aborted) { return; } - util.errorRequest(client, request, err || new RequestAbortedError()); + if (request.completed) { + if (request.upgrade || request.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } + return; + } + util.errorRequest(client, request, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request.aborted || request.completed) { + const abort = (error2) => { + if (request.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request, err); + if (request.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request.onUpgradeError(error2); + }; + const onReady = () => { + try { request.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/verifySignedCommits/index.js b/.github/actions/javascript/verifySignedCommits/index.js index 087b5df387fd..d982728230d3 100644 --- a/.github/actions/javascript/verifySignedCommits/index.js +++ b/.github/actions/javascript/verifySignedCommits/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { diff --git a/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js b/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js index 1e9b71a5c3e0..d5508591a07b 100644 --- a/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js +++ b/.github/actions/javascript/verifyStagingBuildsSucceeded/index.js @@ -1863,10 +1863,66 @@ var require_request = __commonJS({ return false; } } - onUpgrade(statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade(statusCode, headers, socket, statusText = "") { + this.onFinally(); assert(!this.aborted); assert(!this.completed); - return this[kHandler].onUpgrade(statusCode, headers, socket); + if (statusCode !== null) { + this.#publishUpgradeHeaders(statusCode, headers, statusText); + } + const result = this[kHandler].onUpgrade(statusCode, headers, socket); + if (!this.aborted) { + this.completed = true; + if (statusCode !== null) { + this.#publishUpgradeTrailers(); + } + } + return result; + } + /** + * @param {number} statusCode + * @param {import('node:http2').IncomingHttpHeaders} headers + * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders + * @param {string} [statusText] + */ + onUpgradeResponse(statusCode, headers, parseHeaders, statusText = "") { + assert(!this.aborted); + assert(this.completed); + if (channels.headers.hasSubscribers) { + this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText); + } + this.#publishUpgradeTrailers(); + } + /** + * @param {Error} error + */ + onUpgradeError(error2) { + assert(!this.aborted); + assert(this.completed); + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error: error2 }); + } + } + /** + * @param {number} statusCode + * @param {Buffer[]} headers + * @param {string} statusText + */ + #publishUpgradeHeaders(statusCode, headers, statusText) { + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }); + } + } + #publishUpgradeTrailers() { + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }); + } } onComplete(trailers) { this.onFinally(); @@ -1933,7 +1989,11 @@ var require_request = __commonJS({ } else if (typeof val[i] === "object") { throw new InvalidArgumentError(`invalid ${key} header`); } else { - arr.push(`${val[i]}`); + const str = `${val[i]}`; + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } + arr.push(str); } } val = arr; @@ -1945,6 +2005,9 @@ var require_request = __commonJS({ val = ""; } else { val = `${val}`; + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`); + } } if (headerName === "host") { if (request2.host !== null) { @@ -2054,13 +2117,21 @@ var require_dispatcher_base = __commonJS({ var kOnDestroyed = /* @__PURE__ */ Symbol("onDestroyed"); var kOnClosed = /* @__PURE__ */ Symbol("onClosed"); var kInterceptedDispatch = /* @__PURE__ */ Symbol("Intercepted Dispatch"); + var kWebSocketOptions = /* @__PURE__ */ Symbol("webSocketOptions"); var DispatcherBase = class extends Dispatcher { - constructor() { + constructor(opts) { super(); this[kDestroyed] = false; this[kOnDestroyed] = null; this[kClosed] = false; this[kOnClosed] = []; + this[kWebSocketOptions] = opts?.webSocket ?? {}; + } + get webSocketOptions() { + return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, + maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 + }; } get destroyed() { return this[kDestroyed]; @@ -5667,6 +5738,7 @@ var require_client_h1 = __commonJS({ RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -5713,6 +5785,9 @@ var require_client_h1 = __commonJS({ var FastBuffer = Buffer[Symbol.species]; var addListener = util.addListener; var removeAllListeners = util.removeAllListeners; + var kIdleSocketValidation = /* @__PURE__ */ Symbol("kIdleSocketValidation"); + var kIdleSocketValidationTimeout = /* @__PURE__ */ Symbol("kIdleSocketValidationTimeout"); + var kSocketUsed = /* @__PURE__ */ Symbol("kSocketUsed"); var extractBody; async function lazyllhttp() { const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0; @@ -5875,24 +5950,55 @@ var require_client_h1 = __commonJS({ currentBufferRef = null; } const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr; - if (ret === constants3.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)); - } else if (ret === constants3.ERROR.PAUSED) { - this.paused = true; - socket.unshift(data.slice(offset)); - } else if (ret !== constants3.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr); - let message = ""; - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); - message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + if (ret !== constants3.ERROR.OK) { + const body = data.subarray(offset); + if (ret === constants3.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body); + } else if (ret === constants3.ERROR.PAUSED) { + this.paused = true; + socket.unshift(body); + } else { + throw this.createError(ret, body); } - throw new HTTPParserError(message, constants3.ERROR[ret], data.slice(offset)); } } catch (err) { util.destroy(socket, err); } } + finish() { + assert(currentParser === null); + assert(this.ptr != null); + assert(!this.paused); + const { llhttp } = this; + let ret; + try { + currentParser = this; + ret = llhttp.llhttp_finish(this.ptr); + } finally { + currentParser = null; + } + if (ret === constants3.ERROR.OK) { + return null; + } + if (ret === constants3.ERROR.PAUSED || ret === constants3.ERROR.PAUSED_UPGRADE) { + this.paused = true; + return null; + } + return this.createError(ret, EMPTY_BUF); + } + createError(ret, data) { + const { llhttp, contentLength, bytesRead } = this; + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError(); + } + const ptr = llhttp.llhttp_get_error_reason(this.ptr); + let message = ""; + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0); + message = "Response does not match the HTTP/1.1 protocol (" + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + ")"; + } + return new HTTPParserError(message, constants3.ERROR[ret], data); + } destroy() { assert(this.ptr != null); assert(currentParser == null); @@ -5912,6 +6018,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -5955,7 +6065,7 @@ var require_client_h1 = __commonJS({ } } onUpgrade(head) { - const { upgrade, client, socket, headers, statusCode } = this; + const { upgrade, client, socket, headers, statusCode, statusText } = this; assert(upgrade); assert(client[kSocket] === socket); assert(!socket.destroyed); @@ -5980,9 +6090,10 @@ var require_client_h1 = __commonJS({ client[kQueue][client[kRunningIdx]++] = null; client.emit("disconnect", client[kUrl], [client], new InformationalError("upgrade")); try { - request2.onUpgrade(statusCode, headers, socket); - } catch (err) { - util.destroy(socket, err); + request2.onUpgrade(statusCode, headers, socket, statusText); + } catch (error2) { + util.errorRequest(client, request2, error2); + util.destroy(socket, error2); } client[kResume](); } @@ -5991,6 +6102,10 @@ var require_client_h1 = __commonJS({ if (socket.destroyed) { return -1; } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError("bad response", util.getSocketInfo(socket))); + return -1; + } const request2 = client[kQueue][client[kRunningIdx]]; if (!request2) { return -1; @@ -6116,6 +6231,7 @@ var require_client_h1 = __commonJS({ } request2.onComplete(headers); client[kQueue][client[kRunningIdx]++] = null; + socket[kSocketUsed] = true; if (socket[kWriting]) { assert(client[kRunning] === 0); util.destroy(socket, new InformationalError("reset")); @@ -6159,12 +6275,19 @@ var require_client_h1 = __commonJS({ socket[kWriting] = false; socket[kReset] = false; socket[kBlocking] = false; + socket[kIdleSocketValidation] = 0; + socket[kIdleSocketValidationTimeout] = null; + socket[kSocketUsed] = false; socket[kParser] = new Parser(client, socket, llhttpInstance); addListener(socket, "error", function(err) { assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID"); const parser = this[kParser]; if (err.code === "ECONNRESET" && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + this[kError] = parserErr; + this[kClient][kOnError](parserErr); + } return; } this[kError] = err; @@ -6179,7 +6302,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "end", function() { const parser = this[kParser]; if (parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + const parserErr = parser.finish(); + if (parserErr) { + util.destroy(this, parserErr); + } return; } util.destroy(this, new SocketError("other side closed", util.getSocketInfo(this))); @@ -6187,9 +6313,10 @@ var require_client_h1 = __commonJS({ addListener(socket, "close", function() { const client2 = this[kClient]; const parser = this[kParser]; + clearIdleSocketValidation(this); if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - parser.onMessageComplete(); + this[kError] = parser.finish() || this[kError]; } this[kParser].destroy(); this[kParser] = null; @@ -6238,7 +6365,7 @@ var require_client_h1 = __commonJS({ return socket.destroyed; }, busy(request2) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true; } if (request2) { @@ -6256,6 +6383,23 @@ var require_client_h1 = __commonJS({ } }; } + function clearIdleSocketValidation(socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearImmediate(socket[kIdleSocketValidationTimeout]); + socket[kIdleSocketValidationTimeout] = null; + } + socket[kIdleSocketValidation] = 0; + } + function scheduleIdleSocketValidation(client, socket) { + socket[kIdleSocketValidation] = 1; + socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = null; + socket[kIdleSocketValidation] = 2; + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume](); + } + }); + } function resumeH1(client) { const socket = client[kSocket]; if (socket && !socket.destroyed) { @@ -6268,6 +6412,29 @@ var require_client_h1 = __commonJS({ socket.ref(); socket[kNoRef] = false; } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket); + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + return; + } + } + if (client[kRunning] === 0) { + socket[kParser].readMore(); + if (socket.destroyed) { + return; + } + } if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE); @@ -6298,8 +6465,16 @@ var require_client_h1 = __commonJS({ } body = bodyStream.stream; contentLength = bodyStream.length; - } else if (util.isBlobLike(body) && request2.contentType == null && body.type) { - headers.push("content-type", body.type); + } else if (util.isBlobLike(body) && request2.contentType == null) { + const contentType = body.type; + if (contentType) { + const contentTypeValue = `${contentType}`; + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request2, new InvalidArgumentError("invalid content-type header")); + return false; + } + headers.push("content-type", contentTypeValue); + } } if (body && typeof body.read === "function") { body.read(0); @@ -6320,11 +6495,18 @@ var require_client_h1 = __commonJS({ process.emitWarning(new RequestContentLengthMismatchError()); } const socket = client[kSocket]; - const abort = (err) => { - if (request2.aborted || request2.completed) { + clearIdleSocketValidation(socket); + const abort = (error2) => { + if (request2.aborted) { + return; + } + if (request2.completed) { + if (request2.upgrade || request2.method === "CONNECT") { + util.destroy(socket, new InformationalError("aborted")); + } return; } - util.errorRequest(client, request2, err || new RequestAbortedError()); + util.errorRequest(client, request2, error2 || new RequestAbortedError()); util.destroy(body); util.destroy(socket, new InformationalError("aborted")); }; @@ -6680,6 +6862,7 @@ var require_client_h2 = __commonJS({ "node_modules/undici/lib/dispatcher/client-h2.js"(exports, module) { "use strict"; var assert = __require("node:assert"); + var { errorMonitor } = __require("node:events"); var { pipeline } = __require("node:stream"); var util = require_util(); var { @@ -6740,6 +6923,10 @@ var require_client_h2 = __commonJS({ } return result; } + function parseH2ResponseHeaders(headers) { + const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers; + return parseH2Headers(realHeaders); + } async function connectH2(client, socket) { client[kSocket] = socket; if (!h2ExperimentalWarned) { @@ -6903,16 +7090,22 @@ var require_client_h2 = __commonJS({ const { hostname, port } = client[kUrl]; headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ""}`; headers[HTTP2_HEADER_METHOD] = method; - const abort = (err) => { - if (request2.aborted || request2.completed) { + const abort = (error2) => { + if (request2.aborted) { return; } - err = err || new RequestAbortedError(); - util.errorRequest(client, request2, err); + if (request2.completed) { + if (method === "CONNECT" && stream != null) { + util.destroy(stream, error2 || new RequestAbortedError()); + } + return; + } + error2 = error2 || new RequestAbortedError(); + util.errorRequest(client, request2, error2); if (stream != null) { - util.destroy(stream, err); + util.destroy(stream, error2); } - util.destroy(body, err); + util.destroy(body, error2); client[kQueue][client[kRunningIdx]++] = null; client[kResume](); }; @@ -6927,18 +7120,42 @@ var require_client_h2 = __commonJS({ if (method === "CONNECT") { session.ref(); stream = session.request(headers, { endStream: false, signal }); - if (stream.id && !stream.pending) { - request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - } else { - stream.once("ready", () => { + let upgradeResponseFinished = false; + const onResponse = (headers2) => { + upgradeResponseFinished = true; + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeResponse(Number(headers2[HTTP2_HEADER_STATUS]), headers2, parseH2ResponseHeaders); + }; + const onUpgradeError = (error2) => { + upgradeResponseFinished = true; + stream.off("response", onResponse); + request2.onUpgradeError(error2); + }; + const onReady = () => { + try { request2.onUpgrade(null, null, stream); - ++session[kOpenStreams]; - client[kQueue][client[kRunningIdx]++] = null; - }); - } + } catch (error2) { + stream.off("response", onResponse); + abort(error2); + return; + } + if (request2.aborted) { + return; + } + stream.off("error", abort); + stream.once(errorMonitor, onUpgradeError); + client[kQueue][client[kRunningIdx]++] = null; + }; + stream.once("response", onResponse); + stream.once("error", abort); + ++session[kOpenStreams]; + onReady(); stream.once("close", () => { + if (!upgradeResponseFinished && request2.completed) { + stream.off("response", onResponse); + stream.off(errorMonitor, onUpgradeError); + request2.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)); + } session[kOpenStreams] -= 1; if (session[kOpenStreams] === 0) session.unref(); }); @@ -7499,9 +7716,10 @@ var require_client = __commonJS({ autoSelectFamilyAttemptTimeout, // h2 maxConcurrentStreams, - allowH2 + allowH2, + webSocket } = {}) { - super(); + super({ webSocket }); if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); } @@ -8007,8 +8225,8 @@ var require_pool_base = __commonJS({ var kRemoveClient = /* @__PURE__ */ Symbol("remove client"); var kStats = /* @__PURE__ */ Symbol("stats"); var PoolBase = class extends DispatcherBase { - constructor() { - super(); + constructor(opts) { + super(opts); this[kQueue] = new FixedQueue(); this[kClients] = []; this[kQueued] = 0; @@ -8179,7 +8397,6 @@ var require_pool = __commonJS({ allowH2, ...options } = {}) { - super(); if (connections != null && (!Number.isFinite(connections) || connections < 0)) { throw new InvalidArgumentError("invalid connections"); } @@ -8200,6 +8417,7 @@ var require_pool = __commonJS({ ...connect }); } + super(options); this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) ? options.interceptors.Pool : []; this[kConnections] = connections || null; this[kUrl] = util.parseOrigin(origin); @@ -8399,7 +8617,6 @@ var require_agent = __commonJS({ } var Agent = class extends DispatcherBase { constructor({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - super(); if (typeof factory !== "function") { throw new InvalidArgumentError("factory must be a function."); } @@ -8409,6 +8626,7 @@ var require_agent = __commonJS({ if (!Number.isInteger(maxRedirections) || maxRedirections < 0) { throw new InvalidArgumentError("maxRedirections must be a positive number"); } + super(options); if (connect && typeof connect !== "function") { connect = { ...connect }; } @@ -8849,6 +9067,24 @@ var require_retry_handler = __commonJS({ const current = Date.now(); return new Date(retryAfter).getTime() - current; } + function validatePartialResponseContentLength(headers, range, statusCode, retryCount) { + const contentLength = headers["content-length"]; + if (contentLength == null) { + return null; + } + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null; + } + const length = Number(contentLength); + const expectedLength = range.end - range.start + 1; + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError("Content-Length mismatch", statusCode, { + headers, + data: { count: retryCount } + }); + } + return null; + } var RetryHandler = class _RetryHandler { constructor(opts, handlers) { const { retryOptions, ...dispatchOpts } = opts; @@ -8902,6 +9138,7 @@ var require_retry_handler = __commonJS({ this.end = null; this.etag = null; this.resume = null; + this.headersSent = false; this.handler.onConnect((reason) => { this.aborted = true; if (this.abort) { @@ -8911,6 +9148,17 @@ var require_retry_handler = __commonJS({ } }); } + checkpointResponseEnd(headers, resume) { + if (this.end == null && this.opts.method !== "HEAD") { + const contentLength = headers["content-length"]; + this.end = contentLength != null ? Number(contentLength) - 1 : null; + assert( + this.end == null || Number.isFinite(this.end), + "invalid content-length" + ); + } + this.resume = this.end != null ? resume : null; + } onRequestSent() { if (this.handler.onRequestSent) { this.handler.onRequestSent(); @@ -8972,7 +9220,9 @@ var require_retry_handler = __commonJS({ const headers = parseHeaders(rawHeaders); this.retryCount += 1; if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true; + this.checkpointResponseEnd(headers, resume); return this.handler.onHeaders( statusCode, rawHeaders, @@ -9021,9 +9271,21 @@ var require_retry_handler = __commonJS({ ); return false; } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = contentRange; - assert(this.start === start, "content-range mismatch"); - assert(this.end == null || this.end === end, "content-range mismatch"); + if (this.start !== start || this.end != null && this.end !== end) { + this.abort( + new RequestRetryError("Content-Range mismatch", statusCode, { + headers, + data: { count: this.retryCount } + }) + ); + return false; + } this.resume = resume; return true; } @@ -9031,6 +9293,7 @@ var require_retry_handler = __commonJS({ if (statusCode === 206) { const range = parseRangeHeader(headers["content-range"]); if (range == null) { + this.headersSent = true; return this.handler.onHeaders( statusCode, rawHeaders, @@ -9038,6 +9301,11 @@ var require_retry_handler = __commonJS({ statusMessage ); } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount); + if (contentLengthError != null) { + this.abort(contentLengthError); + return false; + } const { start, size, end = size - 1 } = range; assert( start != null && Number.isFinite(start), @@ -9057,6 +9325,7 @@ var require_retry_handler = __commonJS({ "invalid content-length" ); this.resume = resume; + this.headersSent = true; this.etag = headers.etag != null ? headers.etag : null; if (this.etag != null && this.etag.startsWith("W/")) { this.etag = null; @@ -9084,7 +9353,7 @@ var require_retry_handler = __commonJS({ return this.handler.onComplete(rawTrailers); } onError(err) { - if (this.aborted || isDisturbed(this.opts.body)) { + if (this.aborted || isDisturbed(this.opts.body) || this.headersSent && this.resume == null) { return this.handler.onError(err); } if (this.retryCount - this.retryCountCheckpoint > 0) { @@ -15883,14 +16152,48 @@ var require_util6 = __commonJS({ for (let i = 0; i < path.length; ++i) { const code = path.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) - code === 127 || // DEL + code > 126 || // exclude DEL and non-ascii code === 59) { throw new Error("Invalid cookie path"); } } } + function isLetterOrDigit(code) { + return code >= 48 && code <= 57 || // 0-9 + code >= 65 && code <= 90 || // A-Z + code >= 97 && code <= 122; + } function validateCookieDomain(domain) { - if (domain.startsWith("-") || domain.endsWith(".") || domain.endsWith("-")) { + if (domain === " ") { + return; + } + if (domain.length > 255) { + throw new Error("Invalid cookie domain"); + } + let labelLength = 0; + for (let i = 0; i < domain.length; ++i) { + const code = domain.charCodeAt(i); + if (code === 46) { + if (labelLength === 0) { + throw new Error("Invalid cookie domain"); + } + if (domain.charCodeAt(i - 1) === 45) { + throw new Error("Invalid cookie domain"); + } + labelLength = 0; + continue; + } + if (labelLength === 0 && !isLetterOrDigit(code)) { + throw new Error("Invalid cookie domain"); + } + if (!isLetterOrDigit(code) && code !== 45) { + throw new Error("Invalid cookie domain"); + } + if (++labelLength > 63) { + throw new Error("Invalid cookie domain"); + } + } + if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 45) { throw new Error("Invalid cookie domain"); } } @@ -15973,7 +16276,11 @@ var require_util6 = __commonJS({ throw new Error("Invalid unparsed"); } const [key, ...value] = part.split("="); - out.push(`${key.trim()}=${value.join("=")}`); + const trimmedKey = key.trim(); + const joinedValue = value.join("="); + validateCookieName(trimmedKey); + validateCookieValue(joinedValue); + out.push(`${trimmedKey}=${joinedValue}`); } return out.join("; "); } @@ -16103,18 +16410,14 @@ var require_parse = __commonJS({ } else if (attributeNameLowercase === "httponly") { cookieAttributeList.httpOnly = true; } else if (attributeNameLowercase === "samesite") { - let enforcement = "Default"; const attributeValueLowercase = attributeValue.toLowerCase(); - if (attributeValueLowercase.includes("none")) { - enforcement = "None"; - } - if (attributeValueLowercase.includes("strict")) { - enforcement = "Strict"; - } - if (attributeValueLowercase.includes("lax")) { - enforcement = "Lax"; + if (attributeValueLowercase === "none") { + cookieAttributeList.sameSite = "None"; + } else if (attributeValueLowercase === "strict") { + cookieAttributeList.sameSite = "Strict"; + } else if (attributeValueLowercase === "lax") { + cookieAttributeList.sameSite = "Lax"; } - cookieAttributeList.sameSite = enforcement; } else { cookieAttributeList.unparsed ??= []; cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`); @@ -16936,7 +17239,7 @@ var require_connection = __commonJS({ const secProtocol = response.headersList.get("Sec-WebSocket-Protocol"); if (secProtocol !== null) { const requestProtocols = getDecodeSplit("sec-websocket-protocol", request2.headersList); - if (!requestProtocols.includes(secProtocol)) { + if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, "Protocol was not set in the opening handshake."); return; } @@ -17042,31 +17345,26 @@ var require_permessage_deflate = __commonJS({ var tail = Buffer.from([0, 0, 255, 255]); var kBuffer = /* @__PURE__ */ Symbol("kBuffer"); var kLength = /* @__PURE__ */ Symbol("kLength"); - var kDefaultMaxDecompressedSize = 4 * 1024 * 1024; var PerMessageDeflate = class { /** @type {import('node:zlib').InflateRaw} */ #inflate; #options = {}; - /** @type {number} */ - #maxDecompressedSize; - /** @type {boolean} */ - #aborted = false; - /** @type {Function|null} */ - #currentCallback = null; + #maxPayloadSize = 0; /** * @param {Map} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] */ - constructor(extensions, options = {}) { + constructor(extensions, options) { this.#options.serverNoContextTakeover = extensions.has("server_no_context_takeover"); this.#options.serverMaxWindowBits = extensions.get("server_max_window_bits"); - this.#maxDecompressedSize = options.maxDecompressedMessageSize ?? kDefaultMaxDecompressedSize; + this.#maxPayloadSize = options.maxPayloadSize; } + /** + * Decompress a compressed payload. + * @param {Buffer} chunk Compressed data + * @param {boolean} fin Final fragment flag + * @param {Function} callback Callback function + */ decompress(chunk, fin, callback) { - if (this.#aborted) { - callback(new MessageSizeExceededError()); - return; - } if (!this.#inflate) { let windowBits = Z_DEFAULT_WINDOWBITS; if (this.#options.serverMaxWindowBits) { @@ -17085,20 +17383,12 @@ var require_permessage_deflate = __commonJS({ this.#inflate[kBuffer] = []; this.#inflate[kLength] = 0; this.#inflate.on("data", (data) => { - if (this.#aborted) { - return; - } this.#inflate[kLength] += data.length; - if (this.#inflate[kLength] > this.#maxDecompressedSize) { - this.#aborted = true; + if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { + callback(new MessageSizeExceededError()); this.#inflate.removeAllListeners(); this.#inflate.destroy(); this.#inflate = null; - if (this.#currentCallback) { - const cb = this.#currentCallback; - this.#currentCallback = null; - cb(new MessageSizeExceededError()); - } return; } this.#inflate[kBuffer].push(data); @@ -17108,19 +17398,17 @@ var require_permessage_deflate = __commonJS({ callback(err); }); } - this.#currentCallback = callback; this.#inflate.write(chunk); if (fin) { this.#inflate.write(tail); } this.#inflate.flush(() => { - if (this.#aborted || !this.#inflate) { + if (!this.#inflate) { return; } const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]); this.#inflate[kBuffer].length = 0; this.#inflate[kLength] = 0; - this.#currentCallback = null; callback(null, full); }); } @@ -17151,8 +17439,14 @@ var require_receiver = __commonJS({ var { WebsocketFrameSend } = require_frame(); var { closeWebSocketConnection } = require_connection(); var { PerMessageDeflate } = require_permessage_deflate(); + var { MessageSizeExceededError } = require_errors(); + function failWebsocketConnectionWithCode(ws, code, reason) { + closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason)); + failWebsocketConnection(ws, reason); + } var ByteParser = class extends Writable { #buffers = []; + #fragmentsBytes = 0; #byteOffset = 0; #loop = false; #state = parserStates.INFO; @@ -17160,18 +17454,21 @@ var require_receiver = __commonJS({ #fragments = []; /** @type {Map} */ #extensions; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; + /** @type {number} */ + #maxFragments; + /** @type {number} */ + #maxPayloadSize; /** * @param {import('./websocket').WebSocket} ws * @param {Map|null} extensions - * @param {{ maxDecompressedMessageSize?: number }} [options] + * @param {{ maxFragments?: number, maxPayloadSize?: number }} [options] */ constructor(ws, extensions, options = {}) { super(); this.ws = ws; this.#extensions = extensions == null ? /* @__PURE__ */ new Map() : extensions; - this.#options = options; + this.#maxFragments = options.maxFragments ?? 0; + this.#maxPayloadSize = options.maxPayloadSize ?? 0; if (this.#extensions.has("permessage-deflate")) { this.#extensions.set("permessage-deflate", new PerMessageDeflate(extensions, options)); } @@ -17186,6 +17483,13 @@ var require_receiver = __commonJS({ this.#loop = true; this.run(callback); } + #validatePayloadLength() { + if (this.#maxPayloadSize > 0 && !isControlFrame(this.#info.opcode) && this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, "Payload size exceeds maximum allowed size"); + return false; + } + return true; + } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, @@ -17245,6 +17549,9 @@ var require_receiver = __commonJS({ if (payloadLength <= 125) { this.#info.payloadLength = payloadLength; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16; } else if (payloadLength === 127) { @@ -17265,6 +17572,9 @@ var require_receiver = __commonJS({ const buffer = this.consume(2); this.#info.payloadLength = buffer.readUInt16BE(0); this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback(); @@ -17278,6 +17588,9 @@ var require_receiver = __commonJS({ } this.#info.payloadLength = lower; this.#state = parserStates.READ_DATA; + if (!this.#validatePayloadLength()) { + return; + } } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { return callback(); @@ -17288,32 +17601,46 @@ var require_receiver = __commonJS({ this.#state = parserStates.INFO; } else { if (!this.#info.compressed) { - this.#fragments.push(body); + if (!this.writeFragments(body)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } if (!this.#info.fragmented && this.#info.fin) { - const fullMessage = Buffer.concat(this.#fragments); - websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage); - this.#fragments.length = 0; + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); } this.#state = parserStates.INFO; } else { - this.#extensions.get("permessage-deflate").decompress(body, this.#info.fin, (error2, data) => { - if (error2) { - failWebsocketConnection(this.ws, error2.message); - return; - } - this.#fragments.push(data); - if (!this.#info.fin) { - this.#state = parserStates.INFO; + this.#extensions.get("permessage-deflate").decompress( + body, + this.#info.fin, + (error2, data) => { + if (error2) { + const code = error2 instanceof MessageSizeExceededError ? 1009 : 1007; + failWebsocketConnectionWithCode(this.ws, code, error2.message); + return; + } + if (!this.writeFragments(data)) { + return; + } + if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) { + failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message); + return; + } + if (!this.#info.fin) { + this.#state = parserStates.INFO; + this.#loop = true; + this.run(callback); + return; + } + websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments()); this.#loop = true; + this.#state = parserStates.INFO; this.run(callback); - return; } - websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)); - this.#loop = true; - this.#state = parserStates.INFO; - this.#fragments.length = 0; - this.run(callback); - }); + ); this.#loop = false; break; } @@ -17356,6 +17683,26 @@ var require_receiver = __commonJS({ this.#byteOffset -= n; return buffer; } + writeFragments(fragment) { + if (this.#maxFragments > 0 && this.#fragments.length === this.#maxFragments) { + failWebsocketConnectionWithCode(this.ws, 1008, "Too many message fragments"); + return false; + } + this.#fragmentsBytes += fragment.length; + this.#fragments.push(fragment); + return true; + } + consumeFragments() { + const fragments = this.#fragments; + if (fragments.length === 1) { + this.#fragmentsBytes = 0; + return fragments.shift(); + } + const output = Buffer.concat(fragments, this.#fragmentsBytes); + this.#fragments = []; + this.#fragmentsBytes = 0; + return output; + } parseCloseBody(data) { assert(data.length !== 1); let code; @@ -17567,8 +17914,6 @@ var require_websocket = __commonJS({ #extensions = ""; /** @type {SendQueue} */ #sendQueue; - /** @type {{ maxDecompressedMessageSize?: number }} */ - #options; /** * @param {string} url * @param {string|string[]} protocols @@ -17612,9 +17957,6 @@ var require_websocket = __commonJS({ throw new DOMException("Invalid Sec-WebSocket-Protocol value", "SyntaxError"); } this[kWebSocketURL] = new URL(urlRecord.href); - this.#options = { - maxDecompressedMessageSize: options.maxDecompressedMessageSize - }; const client = environmentSettingsObject.settingsObject; this[kController] = establishWebSocketConnection( urlRecord, @@ -17798,7 +18140,13 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this[kResponse] = response; - const parser = new ByteParser(this, parsedExtensions, this.#options); + const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions; + const maxFragments = webSocketOptions?.maxFragments; + const maxPayloadSize = webSocketOptions?.maxPayloadSize; + const parser = new ByteParser(this, parsedExtensions, { + maxFragments, + maxPayloadSize + }); parser.on("drain", onParserDrain); parser.on("error", onParserError.bind(this)); response.socket.ws = this; @@ -17873,19 +18221,6 @@ var require_websocket = __commonJS({ { key: "headers", converter: webidl.nullableConverter(webidl.converters.HeadersInit) - }, - { - key: "maxDecompressedMessageSize", - converter: webidl.nullableConverter((V) => { - V = webidl.converters["unsigned long long"](V); - if (V <= 0) { - throw webidl.errors.exception({ - header: "WebSocket constructor", - message: "maxDecompressedMessageSize must be greater than 0" - }); - } - return V; - }) } ]); webidl.converters["DOMString or sequence or WebSocketInit"] = function(V) { @@ -17964,6 +18299,40 @@ var require_eventsource_stream = __commonJS({ var CR = 13; var COLON = 58; var SPACE = 32; + var DATA = Buffer.from("data"); + var EVENT = Buffer.from("event"); + var ID = Buffer.from("id"); + var RETRY = Buffer.from("retry"); + function isASCIINumberBytes(buffer, start) { + if (start >= buffer.length) { + return false; + } + for (let i = start; i < buffer.length; i++) { + if (buffer[i] < 48 || buffer[i] > 57) { + return false; + } + } + return true; + } + function isValidLastEventIdBytes(buffer, start) { + for (let i = start; i < buffer.length; i++) { + if (buffer[i] === 0) { + return false; + } + } + return true; + } + function isFieldName(line, length, field) { + if (length !== field.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (line[i] !== field[i]) { + return false; + } + } + return true; + } var EventSourceStream = class extends Transform { /** * @type {eventSourceSettings} @@ -17983,10 +18352,13 @@ var require_eventsource_stream = __commonJS({ */ eventEndCheck = false; /** - * @type {Buffer} + * @type {Buffer[]} */ - buffer = null; + chunks = []; + chunkIndex = 0; pos = 0; + lineChunkIndex = 0; + linePos = 0; event = { data: void 0, event: void 0, @@ -18017,63 +18389,30 @@ var require_eventsource_stream = __commonJS({ callback(); return; } - if (this.buffer) { - this.buffer = Buffer.concat([this.buffer, chunk]); - } else { - this.buffer = chunk; - } + this.chunks.push(chunk); if (this.checkBOM) { - switch (this.buffer.length) { - case 1: - if (this.buffer[0] === BOM[0]) { - callback(); - return; - } - this.checkBOM = false; - callback(); - return; - case 2: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1]) { - callback(); - return; - } - this.checkBOM = false; - break; - case 3: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = Buffer.alloc(0); - this.checkBOM = false; - callback(); - return; - } - this.checkBOM = false; - break; - default: - if (this.buffer[0] === BOM[0] && this.buffer[1] === BOM[1] && this.buffer[2] === BOM[2]) { - this.buffer = this.buffer.subarray(3); - } - this.checkBOM = false; - break; + if (this.handleBOM()) { + callback(); + return; } } - while (this.pos < this.buffer.length) { + while (this.hasCurrentByte()) { + const byte = this.currentByte(); if (this.eventEndCheck) { if (this.crlfCheck) { - if (this.buffer[this.pos] === LF) { - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + if (byte === LF) { this.crlfCheck = false; + this.consumeCurrentByte(); continue; } this.crlfCheck = false; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; - if (this.event.data !== void 0 || this.event.event || this.event.id || this.event.retry) { + this.consumeCurrentByte(); + if (this.hasPendingEvent()) { this.processEvent(this.event); } this.clearEvent(); @@ -18082,17 +18421,16 @@ var require_eventsource_stream = __commonJS({ this.eventEndCheck = false; continue; } - if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { - if (this.buffer[this.pos] === CR) { + if (byte === LF || byte === CR) { + if (byte === CR) { this.crlfCheck = true; } - this.parseLine(this.buffer.subarray(0, this.pos), this.event); - this.buffer = this.buffer.subarray(this.pos + 1); - this.pos = 0; + this.parseLine(this.readLine(), this.event); + this.consumeCurrentByte(); this.eventEndCheck = true; continue; } - this.pos++; + this.advanceCursor(); } callback(); } @@ -18108,43 +18446,42 @@ var require_eventsource_stream = __commonJS({ if (colonPosition === 0) { return; } - let field = ""; - let value = ""; + let fieldLength = line.length; + let valueStart = line.length; if (colonPosition !== -1) { - field = line.subarray(0, colonPosition).toString("utf8"); - let valueStart = colonPosition + 1; + fieldLength = colonPosition; + valueStart = colonPosition + 1; if (line[valueStart] === SPACE) { ++valueStart; } - value = line.subarray(valueStart).toString("utf8"); - } else { - field = line.toString("utf8"); - value = ""; } - switch (field) { - case "data": - if (event[field] === void 0) { - event[field] = value; - } else { - event[field] += ` + if (isFieldName(line, fieldLength, DATA)) { + const value = line.toString("utf8", valueStart); + if (event.data === void 0) { + event.data = value; + } else { + event.data += ` ${value}`; - } - break; - case "retry": - if (isASCIINumber(value)) { - event[field] = value; - } - break; - case "id": - if (isValidLastEventId(value)) { - event[field] = value; - } - break; - case "event": - if (value.length > 0) { - event[field] = value; - } - break; + } + return; + } + if (isFieldName(line, fieldLength, RETRY)) { + if (isASCIINumberBytes(line, valueStart)) { + event.retry = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, ID)) { + if (isValidLastEventIdBytes(line, valueStart)) { + event.id = line.toString("utf8", valueStart); + } + return; + } + if (isFieldName(line, fieldLength, EVENT)) { + const value = line.toString("utf8", valueStart); + if (value.length > 0) { + event.event = value; + } } } /** @@ -18169,12 +18506,120 @@ ${value}`; } } clearEvent() { - this.event = { - data: void 0, - event: void 0, - id: void 0, - retry: void 0 - }; + this.event.data = void 0; + this.event.event = void 0; + this.event.id = void 0; + this.event.retry = void 0; + } + hasPendingEvent() { + return this.event.data !== void 0 || this.event.event !== void 0 || this.event.id !== void 0 || this.event.retry !== void 0; + } + hasCurrentByte() { + return this.chunkIndex < this.chunks.length && this.pos < this.chunks[this.chunkIndex].length; + } + currentByte() { + return this.chunks[this.chunkIndex][this.pos]; + } + consumeCurrentByte() { + this.advanceCursor(); + this.syncLineStartToCursor(); + } + advanceCursor() { + this.pos++; + while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { + this.chunkIndex++; + this.pos = 0; + } + } + syncLineStartToCursor() { + this.lineChunkIndex = this.chunkIndex; + this.linePos = this.pos; + this.dropConsumedChunks(); + } + dropConsumedChunks() { + while (this.lineChunkIndex > 0) { + this.chunks.shift(); + this.lineChunkIndex--; + this.chunkIndex--; + } + if (this.chunkIndex === this.chunks.length) { + this.chunks.length = 0; + this.chunkIndex = 0; + this.pos = 0; + this.lineChunkIndex = 0; + this.linePos = 0; + } + } + readLine() { + if (this.lineChunkIndex === this.chunkIndex) { + return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos); + } + const chunks = []; + let length = 0; + for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { + const chunk = this.chunks[i]; + const start = i === this.lineChunkIndex ? this.linePos : 0; + const end = i === this.chunkIndex ? this.pos : chunk.length; + const slice = chunk.subarray(start, end); + length += slice.length; + chunks.push(slice); + } + return Buffer.concat(chunks, length); + } + peekBufferedByte(offset) { + let chunkIndex = this.lineChunkIndex; + let pos = this.linePos; + while (chunkIndex < this.chunks.length) { + const chunk = this.chunks[chunkIndex]; + const remaining = chunk.length - pos; + if (offset < remaining) { + return chunk[pos + offset]; + } + offset -= remaining; + chunkIndex++; + pos = 0; + } + } + discardLeadingBytes(count) { + while (count > 0 && this.lineChunkIndex < this.chunks.length) { + const chunk = this.chunks[this.lineChunkIndex]; + const remaining = chunk.length - this.linePos; + if (count < remaining) { + this.linePos += count; + count = 0; + } else { + count -= remaining; + this.lineChunkIndex++; + this.linePos = 0; + } + } + this.chunkIndex = this.lineChunkIndex; + this.pos = this.linePos; + this.dropConsumedChunks(); + } + handleBOM() { + const first = this.peekBufferedByte(0); + const second = this.peekBufferedByte(1); + const third = this.peekBufferedByte(2); + if (second === void 0) { + if (first === BOM[0]) { + return true; + } + this.checkBOM = false; + return true; + } + if (third === void 0) { + if (first === BOM[0] && second === BOM[1]) { + return true; + } + this.checkBOM = false; + return false; + } + if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { + this.discardLeadingBytes(3); + } + this.checkBOM = false; + return !this.hasCurrentByte(); } }; module.exports = { From 3f53b8f63bd3b6a416293ab3a7b75f3b3c230dd9 Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Mon, 12 Oct 2026 05:54:17 +0000 Subject: [PATCH 440/781] Remove review-flagged comments Co-authored-by: huult --- src/hooks/usePolicyForMovingExpenses.ts | 3 --- src/pages/Search/EmptySearchView.tsx | 2 -- 2 files changed, 5 deletions(-) diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 732502112d89..7cc5f96cd51b 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -22,7 +22,6 @@ function checkForUserPendingDelete(login: string, policy: OnyxEntry) { return isPolicyMemberWithoutPendingDelete(login, policy); } -// `policy.role` is only set on the active policy, so fall back to `getPolicyRole` (matches `getGroupPoliciesWhereReportCanBeCreated`). function isPolicyMemberByRole(policy: OnyxEntry, login: string) { const role = getPolicyRole(policy, login); return !!role && (Object.values(CONST.POLICY.ROLE) as string[]).includes(role); @@ -130,9 +129,7 @@ function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: return {policyForMovingExpensesID: undefined, policyForMovingExpenses: undefined, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } - // User has no eligible policy if (!resolvedPolicyID) { - // The active workspace may still be valid when qualification finds nothing, so check it before the upgrade path. if (isPolicyValidForMovingExpenses(activePolicy, login, isPerDiemRequest, isTimeRequest)) { return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false, arePoliciesLoaded}; } diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 39fbdeae163c..3392b5e910b2 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -87,8 +87,6 @@ function EmptySearchView({similarSearchHash, type, hasResults, queryJSON, violat const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); - // The login is required for the `employeeList` role fallback in `getPolicyRole`. Without it, every policy - // that only carries the member's role in `employeeList` is filtered out and the user looks workspace-less. const groupPoliciesWithChatEnabled = getGroupPoliciesWhereReportCanBeCreated(allPolicies, currentUserPersonalDetails?.login); const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { From 84df4765b56987471550c9f262c06466bb90c8fe Mon Sep 17 00:00:00 2001 From: X Developer Date: Mon, 12 Oct 2026 08:28:37 +0200 Subject: [PATCH 441/781] Fix blank-space long press on selectable expense rows --- .../MoneyRequestReportTransactionItem.tsx | 31 ++++++-------- ...stReportTransactionItemRejectErrorTest.tsx | 42 +++++++++++++++++-- .../hooks/useCopyableTextRowPress.test.ts | 34 ++++++++++++++- 3 files changed, 85 insertions(+), 22 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index be7def652d03..7b1845a2d003 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -5,7 +5,7 @@ import {PressableWithFeedback} from '@components/Pressable'; import type {SearchColumnType, TableColumnSize} from '@components/Search/types'; import TransactionItemRow from '@components/TransactionItemRow'; -import useCopyableTextRowPress from '@hooks/useCopyableTextRowPress'; +import useCopyableTextRowPress, {isPressStartOnCopyableText} from '@hooks/useCopyableTextRowPress'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; @@ -101,14 +101,6 @@ type MoneyRequestReportTransactionItemBodyProps = Omit { if (!wasRecentlyEditingCell) { @@ -220,18 +212,20 @@ function MoneyRequestReportTransactionItemBody({ handleOnPress(transaction.transactionID); }; - const handlePressIn: React.ComponentProps['onPressIn'] = (event) => { + const handleTouchStart: React.ComponentProps['onTouchStart'] = (event) => { + const isCopyableTextTouchStart = isPressStartOnCopyableText(event); + wasTouchStartOnCopyableTextRef.current = markTouchStartOnCopyableText(event, isCopyableTextTouchStart); + }; + + const handlePressIn: React.ComponentProps['onPressIn'] = () => { wasEditingOnPressInRef.current = isEditingCell; - wasPressInOnCopyableTextRef.current = false; // Selection only needs to be blocked for touch interactions; desktop mouse selection is handled by onMouseDown. if (!canUseTouchScreen()) { return; } // Let copyable values use native long-press/drag selection instead of applying the global selection blocker. - const isCopyableTarget = markMouseDownOnCopyableText(getPressEventTarget(event?.target)); - wasPressInOnCopyableTextRef.current = isCopyableTarget; - if (isCopyableTarget) { + if (wasTouchStartOnCopyableTextRef.current) { return; } @@ -241,14 +235,14 @@ function MoneyRequestReportTransactionItemBody({ const handlePressableLongPress = () => { // Let touch-web long presses on copyable values complete native text selection without opening row actions. - if (wasPressInOnCopyableTextRef.current) { + if (wasTouchStartOnCopyableTextRef.current) { return; } handleLongPress(transaction.transactionID); }; const handlePressOut = () => { - wasPressInOnCopyableTextRef.current = false; + wasTouchStartOnCopyableTextRef.current = false; ControlSelection.unblock(); }; @@ -281,6 +275,7 @@ function MoneyRequestReportTransactionItemBody({ hoverStyle={[isSelectable && !shouldDisableHoverStyle && styles.hoveredComponentBG, isSelected && styles.activeComponentBG]} dataSet={COPYABLE_ROW_DATA_SET} onMouseDown={handleMouseDown} + onTouchStart={handleTouchStart} onHoverIn={handleHoverIn} onPressIn={handlePressIn} onPressOut={handlePressOut} diff --git a/tests/ui/MoneyRequestReportTransactionItemRejectErrorTest.tsx b/tests/ui/MoneyRequestReportTransactionItemRejectErrorTest.tsx index a8de9d6ddda3..bd507d4ea0b7 100644 --- a/tests/ui/MoneyRequestReportTransactionItemRejectErrorTest.tsx +++ b/tests/ui/MoneyRequestReportTransactionItemRejectErrorTest.tsx @@ -6,6 +6,10 @@ import MoneyRequestReportTransactionItem from '@components/MoneyRequestReportVie import OnyxListItemProvider from '@components/OnyxListItemProvider'; import ScreenWrapper from '@components/ScreenWrapper'; +import type * as CopyableTextRowPress from '@hooks/useCopyableTextRowPress'; + +import canUseTouchScreen from '@libs/DeviceCapabilities/canUseTouchScreen'; + import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,6 +26,15 @@ import {translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@react-navigation/native'); +jest.mock('@libs/DeviceCapabilities/canUseTouchScreen', () => jest.fn()); + +const mockIsPressStartOnCopyableText = jest.fn(); +jest.mock('@hooks/useCopyableTextRowPress', () => { + const actual = jest.requireActual('@hooks/useCopyableTextRowPress'); + return {...actual, __esModule: true, default: actual.default, isPressStartOnCopyableText: (event: unknown) => mockIsPressStartOnCopyableText(event)}; +}); + +const mockedCanUseTouchScreen = jest.mocked(canUseTouchScreen); const REPORT_ID = '12345'; const TRANSACTION_ID = '67890'; @@ -62,6 +75,11 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { return waitForBatchedUpdatesWithAct(); }); + beforeEach(() => { + mockedCanUseTouchScreen.mockReturnValue(false); + mockIsPressStartOnCopyableText.mockReturnValue(false); + }); + afterEach(async () => { await act(async () => { await Onyx.clear(); @@ -70,7 +88,7 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { await waitForBatchedUpdatesWithAct(); }); - async function renderTransactionItem(transaction: Transaction, handleOnPress = jest.fn()) { + async function renderTransactionItem(transaction: Transaction, handleOnPress = jest.fn(), handleLongPress = jest.fn()) { await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); @@ -89,7 +107,7 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { isSelectionModeEnabled={false} toggleTransaction={jest.fn()} handleOnPress={handleOnPress} - handleLongPress={jest.fn()} + handleLongPress={handleLongPress} isSelected={false} dateColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} postedColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} @@ -105,7 +123,7 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { ); await waitForBatchedUpdatesWithAct(); - return {handleOnPress}; + return {handleLongPress, handleOnPress}; } it('should show the translated copy instead of the English message the server sent', async () => { @@ -147,6 +165,24 @@ describe('MoneyRequestReportTransactionItem - reject errors', () => { expect(handleOnPress).toHaveBeenCalledWith(TRANSACTION_ID); }); + it('should open row actions when a long press starts in blank space inside a copyable cell', async () => { + // Given a touch device and a copyable cell whose element extends beyond its text + mockedCanUseTouchScreen.mockReturnValue(true); + mockIsPressStartOnCopyableText.mockReturnValue(false); + const copyableCell = document.createElement('span'); + copyableCell.setAttribute(`data-${CONST.COPYABLE_TEXT_ELEMENT}`, 'true'); + const {handleLongPress} = await renderTransactionItem(buildTransaction()); + const row = screen.getByLabelText('View details'); + + // When the user long-presses the cell element outside the text glyphs + fireEvent(row, 'touchStart', {target: copyableCell}); + fireEvent(row, 'pressIn', {target: copyableCell}); + fireEvent(row, 'longPress'); + + // Then the row action remains available instead of treating the blank area as selectable text + expect(handleLongPress).toHaveBeenCalledWith(TRANSACTION_ID); + }); + it('should clear the reject error when it is dismissed', async () => { // Given: An expense left behind by a reject the server refused await renderTransactionItem(buildTransactionWithRejectError()); diff --git a/tests/unit/hooks/useCopyableTextRowPress.test.ts b/tests/unit/hooks/useCopyableTextRowPress.test.ts index 4d2295185c19..4ba68ed38366 100644 --- a/tests/unit/hooks/useCopyableTextRowPress.test.ts +++ b/tests/unit/hooks/useCopyableTextRowPress.test.ts @@ -1,8 +1,13 @@ import {renderHook} from '@testing-library/react-native'; import type useCopyableTextRowPressModule from '../../../src/hooks/useCopyableTextRowPress/index'; +import type {isPressStartOnCopyableText as isPressStartOnCopyableTextModule} from '../../../src/hooks/useCopyableTextRowPress/index'; -const useCopyableTextRowPress = jest.requireActual<{default: typeof useCopyableTextRowPressModule}>('../../../src/hooks/useCopyableTextRowPress/index.ts').default; +const copyableTextRowPressModule = jest.requireActual<{ + default: typeof useCopyableTextRowPressModule; + isPressStartOnCopyableText: typeof isPressStartOnCopyableTextModule; +}>('../../../src/hooks/useCopyableTextRowPress/index.ts'); +const {default: useCopyableTextRowPress, isPressStartOnCopyableText} = copyableTextRowPressModule; const originalGetClientRectsDescriptor = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects'); function getCopyableTextElement(): HTMLElement { @@ -103,4 +108,31 @@ describe('useCopyableTextRowPress', () => { expect(result.current.isPressStartOnCopyableText(pressEvent)).toBe(true); expect(getClientRectsMock).toHaveBeenCalledTimes(1); }); + + it('does not treat blank space inside a copyable element as copyable text', () => { + // Given a copyable element whose layout extends beyond its rendered text + const copyableTextElement = getCopyableTextElement(); + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => [ + { + bottom: 20, + height: 20, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }, + ], + }); + + // When the touch starts within the element but outside the text glyph bounds + const isCopyableText = isPressStartOnCopyableText({clientX: 150, clientY: 10, target: copyableTextElement}); + + // Then the blank area remains available for the row long-press action + expect(isCopyableText).toBe(false); + }); }); From a43db490c97095f14f1f4e08b25199f203a1ae62 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 12 Oct 2026 09:33:57 +0200 Subject: [PATCH 442/781] Read the chat last actor by ID for report preview avatars --- .../useReportPreviewSenderID.ts | 26 ++++++- src/selectors/Report.ts | 5 ++ tests/unit/getReportPreviewSenderIDTest.ts | 2 +- tests/unit/useReportPreviewSenderIDTest.tsx | 72 ++++++++++++++++++- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 04b32bd0d0ea..dda689a5c839 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -13,6 +13,7 @@ import {isDM, isIOUReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {getReportLastActorAccountID} from '@src/selectors/Report'; import {hasOnceLoadedReportActionsSelector, isLoadingInitialReportActionsSelector} from '@src/selectors/ReportMetaData'; import type {OriginalMessageIOU, Policy, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; @@ -76,12 +77,17 @@ function hasPendingScanStateAndUnknownDirection(transaction: Transaction): boole return true; } -function getPendingScanActorAccountID(transaction: Transaction, action: OnyxEntry, iouReport: OnyxEntry, chatReport: OnyxEntry): number | undefined { +function getPendingScanActorAccountID( + transaction: Transaction, + action: OnyxEntry, + iouReport: OnyxEntry, + chatReportLastActorAccountID: number | undefined, +): number | undefined { if (!hasPendingScanStateAndUnknownDirection(transaction)) { return undefined; } - const chatLastActorAccountID = Number(chatReport?.lastActorAccountID); + const chatLastActorAccountID = Number(chatReportLastActorAccountID); const validPendingScanActorAccountIDs = new Set([action?.childOwnerAccountID, action?.childManagerAccountID, iouReport?.ownerAccountID, iouReport?.managerID].filter(Boolean)); if ( @@ -138,6 +144,9 @@ type GetReportPreviewSenderIDParams = { iouReport: OnyxEntry; action: OnyxEntry; chatReport: OnyxEntry; + + /** Last actor of the chat report, passed on its own because `chatReport` can be a projection without the `last*` fields */ + chatReportLastActorAccountID?: number; iouActions: ReportAction[] | undefined; transactions: Transaction[] | undefined; attendeesPersonalDetails: PersonalDetailsByLogin; @@ -151,6 +160,7 @@ function getReportPreviewSenderID({ iouReport, action, chatReport, + chatReportLastActorAccountID, iouActions, transactions, attendeesPersonalDetails, @@ -192,7 +202,10 @@ function getReportPreviewSenderID({ } const transactionActorAccountIDs = transactions?.map((transaction) => { - return getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID ?? getPendingScanActorAccountID(transaction, action, iouReport, chatReport); + return ( + getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID ?? + getPendingScanActorAccountID(transaction, action, iouReport, chatReportLastActorAccountID) + ); }); const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; @@ -315,6 +328,12 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx return filteredTransactions; }, [reportTransactions, iouActions, shouldFetchData, iouReport?.transactionCount, action?.childMoneyRequestCount]); + // Read by ID rather than from `chatReport`, which can be a projection without the `last*` fields. This re-renders only + // when the last actor changes, not on every message. + const [chatReportLastActorAccountID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(shouldFetchData ? chatReport?.reportID : undefined)}`, { + selector: getReportLastActorAccountID, + }); + const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(shouldFetchData ? chatReport?.reportID : undefined)}`, { selector: getSplitsSelector, }); @@ -328,6 +347,7 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx iouReport, action, chatReport, + chatReportLastActorAccountID, iouActions, transactions, attendeesPersonalDetails, diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index 59d0e24a1f81..420af8121b15 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -39,6 +39,10 @@ function getReportPolicyID(report: OnyxEntry) { return report?.policyID; } +function getReportLastActorAccountID(report: OnyxEntry) { + return report?.lastActorAccountID; +} + function getReportOwnerAccountID(report: OnyxEntry) { return report?.ownerAccountID; } @@ -387,6 +391,7 @@ export { getReportOwnerAccountID, getReportParentReportID, getReportPolicyID, + getReportLastActorAccountID, policyIDsWithEmptyReportsSelector, canShowReportRecipientLocalTimeSelector, policyChatRoomsSelector, diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index daf99da8b6e2..f38f92499a04 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -336,7 +336,6 @@ describe('getReportPreviewSenderID', () => { const dmChat = createMock({ reportID: 'dm-1', type: CONST.REPORT.TYPE.CHAT, - lastActorAccountID: MANAGER_ACCOUNT_ID, participants: { [OWNER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, [MANAGER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, @@ -346,6 +345,7 @@ describe('getReportPreviewSenderID', () => { const result = getReportPreviewSenderID({ ...baseParams, chatReport: dmChat, + chatReportLastActorAccountID: MANAGER_ACCOUNT_ID, iouReport: makeIOUReport({transactionCount: 2, ownerAccountID: OWNER_ACCOUNT_ID, managerID: MANAGER_ACCOUNT_ID}), action: makeAction({childMoneyRequestCount: 2, childLastActorAccountID: OWNER_ACCOUNT_ID}), iouActions: [ diff --git a/tests/unit/useReportPreviewSenderIDTest.tsx b/tests/unit/useReportPreviewSenderIDTest.tsx index e43c96dadb0a..9706a6203209 100644 --- a/tests/unit/useReportPreviewSenderIDTest.tsx +++ b/tests/unit/useReportPreviewSenderIDTest.tsx @@ -9,7 +9,8 @@ import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; +import {getStableReportSelector} from '@src/selectors/Report'; +import type {Report, ReportAction, Transaction} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import React from 'react'; @@ -200,4 +201,73 @@ describe('useReportPreviewSenderID', () => { await waitForBatchedUpdatesWithAct(); expect(result.current).toBe(CURRENT_USER_ACCOUNT_ID); }); + + it('reads the chat last actor by ID when the chat report is a projection without the last* fields', async () => { + // Given a DM where the manager just scanned a receipt: one request from the owner, one scan still pending with no + // amount yet, and the chat's last actor being the manager + const OWNER_ACCOUNT_ID = 200; + const MANAGER_ACCOUNT_ID = 300; + const dmChat: Report = { + reportID: '9001', + type: CONST.REPORT.TYPE.CHAT, + lastActorAccountID: MANAGER_ACCOUNT_ID, + participants: { + [OWNER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [MANAGER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + const iouReport: Report = { + reportID: '9002', + chatReportID: dmChat.reportID, + type: CONST.REPORT.TYPE.IOU, + transactionCount: 2, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + }; + const ownerRequest: Transaction = {...transactionR14932, transactionID: '9101', reportID: iouReport.reportID, amount: 1200}; + const pendingScan: Transaction = { + ...transactionR14932, + transactionID: '9102', + reportID: iouReport.reportID, + amount: 0, + receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, + }; + const ownerRequestAction = { + ...actionR14932, + reportActionID: '9201', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: OWNER_ACCOUNT_ID, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: ownerRequest.transactionID, amount: 1200, currency: 'USD'}, + } as ReportAction; + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${dmChat.reportID}`, dmChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, {[ownerRequestAction.reportActionID]: ownerRequestAction}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${ownerRequest.transactionID}`, ownerRequest); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${pendingScan.transactionID}`, pendingScan); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${iouReport.reportID}`, {hasOnceLoadedReportActions: true}); + await waitForBatchedUpdatesWithAct(); + }); + + // When the avatars get the chat report as the stable projection, which drops `lastActorAccountID` + const {result} = renderHook( + () => + useReportPreviewSenderID({ + action: { + ...validAction, + childReportID: iouReport.reportID, + childMoneyRequestCount: 2, + childOwnerAccountID: OWNER_ACCOUNT_ID, + childManagerAccountID: MANAGER_ACCOUNT_ID, + childLastActorAccountID: OWNER_ACCOUNT_ID, + }, + iouReport, + chatReport: getStableReportSelector(dmChat), + }), + {wrapper}, + ); + await waitForBatchedUpdatesWithAct(); + + // Then the pending scan is still attributed to the manager, so the preview keeps both avatars instead of collapsing to the owner + expect(result.current).toBeUndefined(); + }); }); From ff40d0cf592cb3a2979c9099efaf0a0744659135 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 12 Oct 2026 10:28:21 +0200 Subject: [PATCH 443/781] Revert stable report projections in action avatars for now --- src/components/ReportActionAvatars/index.tsx | 3 +- .../useReportActionAvatars.ts | 7 +- .../useReportPreviewSenderID.ts | 26 +------ src/selectors/Report.ts | 5 -- tests/unit/getReportPreviewSenderIDTest.ts | 2 +- tests/unit/useReportPreviewSenderIDTest.tsx | 72 +------------------ 6 files changed, 9 insertions(+), 106 deletions(-) diff --git a/src/components/ReportActionAvatars/index.tsx b/src/components/ReportActionAvatars/index.tsx index a90d735c6bff..3970c3121711 100644 --- a/src/components/ReportActionAvatars/index.tsx +++ b/src/components/ReportActionAvatars/index.tsx @@ -17,7 +17,6 @@ import {sortIconsByName} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {getStableReportSelector} from '@src/selectors/Report'; import type {InvitedEmailsToAccountIDs, Policy, Report, ReportAction} from '@src/types/onyx'; import type {CardFeed} from '@src/types/onyx/CardFeeds'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; @@ -126,7 +125,7 @@ function ReportActionAvatars({ // reportID can be an empty string causing Onyx to fetch the whole collection // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const [reportFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID || undefined}`, {selector: getStableReportSelector}); + const [reportFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID || undefined}`); // When the search hash changes, report from the snapshot will be undefined if it hasn't been fetched yet. // Therefore, we will fall back to reportProp while the data is being fetched. const report = reportFromOnyx ?? reportProp; diff --git a/src/components/ReportActionAvatars/useReportActionAvatars.ts b/src/components/ReportActionAvatars/useReportActionAvatars.ts index a4320ee71678..05fecf1a87c8 100644 --- a/src/components/ReportActionAvatars/useReportActionAvatars.ts +++ b/src/components/ReportActionAvatars/useReportActionAvatars.ts @@ -33,7 +33,6 @@ import {buildUserIcon} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {getReportPolicyID, getStableReportSelector} from '@src/selectors/Report'; import {getReportActionByIDSelector} from '@src/selectors/ReportAction'; import {pendingDeleteMemberAccountIDsSelector} from '@src/selectors/ReportMetaData'; import type {InvitedEmailsToAccountIDs, OnyxInputOrEntry, Policy, Report, ReportAction} from '@src/types/onyx'; @@ -83,7 +82,7 @@ function useReportActionAvatars({ const isReportAChatReport = report?.type === CONST.REPORT.TYPE.CHAT && report?.chatType !== CONST.REPORT.CHAT_TYPE.TRIP_ROOM; const chatReportID = report?.chatReportID ?? passedChatReportID; - const [reportChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, {selector: getStableReportSelector}); + const [reportChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); const chatReport = isReportAChatReport ? report : reportChatReport; const iouReport = isReportAChatReport ? undefined : report; @@ -101,7 +100,7 @@ function useReportActionAvatars({ // Support tickets use the assigned rep's avatar, not the parent message's author. const action = passedAction ?? (isSupportTicket(report) ? undefined : derivedAction); - const [actionChildReportPolicyID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${action?.childReportID}`, {selector: getReportPolicyID}); + const [actionChildReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${action?.childReportID}`); const isAReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; @@ -115,7 +114,7 @@ function useReportActionAvatars({ const reportPolicyID = iouReport?.policyID ?? chatReport?.policyID; const chatReportPolicyIDExists = chatReport?.policyID === CONST.POLICY.ID_FAKE || !chatReport?.policyID; - const changedPolicyID = actionChildReportPolicyID ?? iouReport?.policyID; + const changedPolicyID = actionChildReport?.policyID ?? iouReport?.policyID; const shouldUseChangedPolicyID = !!changedPolicyID && changedPolicyID !== (reportPolicyID ?? iouReport?.policyID); const retrievedPolicyID = chatReportPolicyIDExists ? reportPolicyID : chatReport?.policyID; diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index dda689a5c839..04b32bd0d0ea 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -13,7 +13,6 @@ import {isDM, isIOUReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {getReportLastActorAccountID} from '@src/selectors/Report'; import {hasOnceLoadedReportActionsSelector, isLoadingInitialReportActionsSelector} from '@src/selectors/ReportMetaData'; import type {OriginalMessageIOU, Policy, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; @@ -77,17 +76,12 @@ function hasPendingScanStateAndUnknownDirection(transaction: Transaction): boole return true; } -function getPendingScanActorAccountID( - transaction: Transaction, - action: OnyxEntry, - iouReport: OnyxEntry, - chatReportLastActorAccountID: number | undefined, -): number | undefined { +function getPendingScanActorAccountID(transaction: Transaction, action: OnyxEntry, iouReport: OnyxEntry, chatReport: OnyxEntry): number | undefined { if (!hasPendingScanStateAndUnknownDirection(transaction)) { return undefined; } - const chatLastActorAccountID = Number(chatReportLastActorAccountID); + const chatLastActorAccountID = Number(chatReport?.lastActorAccountID); const validPendingScanActorAccountIDs = new Set([action?.childOwnerAccountID, action?.childManagerAccountID, iouReport?.ownerAccountID, iouReport?.managerID].filter(Boolean)); if ( @@ -144,9 +138,6 @@ type GetReportPreviewSenderIDParams = { iouReport: OnyxEntry; action: OnyxEntry; chatReport: OnyxEntry; - - /** Last actor of the chat report, passed on its own because `chatReport` can be a projection without the `last*` fields */ - chatReportLastActorAccountID?: number; iouActions: ReportAction[] | undefined; transactions: Transaction[] | undefined; attendeesPersonalDetails: PersonalDetailsByLogin; @@ -160,7 +151,6 @@ function getReportPreviewSenderID({ iouReport, action, chatReport, - chatReportLastActorAccountID, iouActions, transactions, attendeesPersonalDetails, @@ -202,10 +192,7 @@ function getReportPreviewSenderID({ } const transactionActorAccountIDs = transactions?.map((transaction) => { - return ( - getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID ?? - getPendingScanActorAccountID(transaction, action, iouReport, chatReportLastActorAccountID) - ); + return getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID ?? getPendingScanActorAccountID(transaction, action, iouReport, chatReport); }); const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; @@ -328,12 +315,6 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx return filteredTransactions; }, [reportTransactions, iouActions, shouldFetchData, iouReport?.transactionCount, action?.childMoneyRequestCount]); - // Read by ID rather than from `chatReport`, which can be a projection without the `last*` fields. This re-renders only - // when the last actor changes, not on every message. - const [chatReportLastActorAccountID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(shouldFetchData ? chatReport?.reportID : undefined)}`, { - selector: getReportLastActorAccountID, - }); - const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(shouldFetchData ? chatReport?.reportID : undefined)}`, { selector: getSplitsSelector, }); @@ -347,7 +328,6 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx iouReport, action, chatReport, - chatReportLastActorAccountID, iouActions, transactions, attendeesPersonalDetails, diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index 613b3d67639e..79c50694ed48 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -41,10 +41,6 @@ function getReportPolicyID(report: OnyxEntry) { return report?.policyID; } -function getReportLastActorAccountID(report: OnyxEntry) { - return report?.lastActorAccountID; -} - function getReportOwnerAccountID(report: OnyxEntry) { return report?.ownerAccountID; } @@ -405,7 +401,6 @@ export { getReportOwnerAccountID, getReportParentReportID, getReportPolicyID, - getReportLastActorAccountID, policyIDsWithEmptyReportsSelector, canShowReportRecipientLocalTimeSelector, policyChatRoomsSelector, diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index f38f92499a04..daf99da8b6e2 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -336,6 +336,7 @@ describe('getReportPreviewSenderID', () => { const dmChat = createMock({ reportID: 'dm-1', type: CONST.REPORT.TYPE.CHAT, + lastActorAccountID: MANAGER_ACCOUNT_ID, participants: { [OWNER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, [MANAGER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, @@ -345,7 +346,6 @@ describe('getReportPreviewSenderID', () => { const result = getReportPreviewSenderID({ ...baseParams, chatReport: dmChat, - chatReportLastActorAccountID: MANAGER_ACCOUNT_ID, iouReport: makeIOUReport({transactionCount: 2, ownerAccountID: OWNER_ACCOUNT_ID, managerID: MANAGER_ACCOUNT_ID}), action: makeAction({childMoneyRequestCount: 2, childLastActorAccountID: OWNER_ACCOUNT_ID}), iouActions: [ diff --git a/tests/unit/useReportPreviewSenderIDTest.tsx b/tests/unit/useReportPreviewSenderIDTest.tsx index 9706a6203209..e43c96dadb0a 100644 --- a/tests/unit/useReportPreviewSenderIDTest.tsx +++ b/tests/unit/useReportPreviewSenderIDTest.tsx @@ -9,8 +9,7 @@ import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {getStableReportSelector} from '@src/selectors/Report'; -import type {Report, ReportAction, Transaction} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import React from 'react'; @@ -201,73 +200,4 @@ describe('useReportPreviewSenderID', () => { await waitForBatchedUpdatesWithAct(); expect(result.current).toBe(CURRENT_USER_ACCOUNT_ID); }); - - it('reads the chat last actor by ID when the chat report is a projection without the last* fields', async () => { - // Given a DM where the manager just scanned a receipt: one request from the owner, one scan still pending with no - // amount yet, and the chat's last actor being the manager - const OWNER_ACCOUNT_ID = 200; - const MANAGER_ACCOUNT_ID = 300; - const dmChat: Report = { - reportID: '9001', - type: CONST.REPORT.TYPE.CHAT, - lastActorAccountID: MANAGER_ACCOUNT_ID, - participants: { - [OWNER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [MANAGER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - }; - const iouReport: Report = { - reportID: '9002', - chatReportID: dmChat.reportID, - type: CONST.REPORT.TYPE.IOU, - transactionCount: 2, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - }; - const ownerRequest: Transaction = {...transactionR14932, transactionID: '9101', reportID: iouReport.reportID, amount: 1200}; - const pendingScan: Transaction = { - ...transactionR14932, - transactionID: '9102', - reportID: iouReport.reportID, - amount: 0, - receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, - }; - const ownerRequestAction = { - ...actionR14932, - reportActionID: '9201', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - actorAccountID: OWNER_ACCOUNT_ID, - originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: ownerRequest.transactionID, amount: 1200, currency: 'USD'}, - } as ReportAction; - await act(async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${dmChat.reportID}`, dmChat); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, {[ownerRequestAction.reportActionID]: ownerRequestAction}); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${ownerRequest.transactionID}`, ownerRequest); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${pendingScan.transactionID}`, pendingScan); - await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${iouReport.reportID}`, {hasOnceLoadedReportActions: true}); - await waitForBatchedUpdatesWithAct(); - }); - - // When the avatars get the chat report as the stable projection, which drops `lastActorAccountID` - const {result} = renderHook( - () => - useReportPreviewSenderID({ - action: { - ...validAction, - childReportID: iouReport.reportID, - childMoneyRequestCount: 2, - childOwnerAccountID: OWNER_ACCOUNT_ID, - childManagerAccountID: MANAGER_ACCOUNT_ID, - childLastActorAccountID: OWNER_ACCOUNT_ID, - }, - iouReport, - chatReport: getStableReportSelector(dmChat), - }), - {wrapper}, - ); - await waitForBatchedUpdatesWithAct(); - - // Then the pending scan is still attributed to the manager, so the preview keeps both avatars instead of collapsing to the owner - expect(result.current).toBeUndefined(); - }); }); From 4d329246b7eb0400284247f1e024dbbb6e1846d6 Mon Sep 17 00:00:00 2001 From: daledah Date: Mon, 12 Oct 2026 15:39:32 +0700 Subject: [PATCH 444/781] fix ts error --- src/pages/settings/EmailIssue/index.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/pages/settings/EmailIssue/index.tsx b/src/pages/settings/EmailIssue/index.tsx index fc65099fe46a..363fc8aa439b 100644 --- a/src/pages/settings/EmailIssue/index.tsx +++ b/src/pages/settings/EmailIssue/index.tsx @@ -26,7 +26,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useIsFocused} from '@react-navigation/native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -42,7 +42,9 @@ function EmailIssuePage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const hasEmailDeliveryFailure = !!account?.hasEmailDeliveryFailure; @@ -122,7 +124,15 @@ function EmailIssuePage() { + onRemove(row.id)} + > + + + + + + + ))} + {canAddMore && ( + + )} + {!!errorText && ( + + + + )} + + ); +} + +export default ListField; +export type {ListFieldRow}; diff --git a/src/components/SubStepForms/ConfirmationStep.tsx b/src/components/SubStepForms/ConfirmationStep.tsx index c5de26d7c618..130955099405 100644 --- a/src/components/SubStepForms/ConfirmationStep.tsx +++ b/src/components/SubStepForms/ConfirmationStep.tsx @@ -1,6 +1,7 @@ import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import RenderHTML from '@components/RenderHTML'; import ScrollView from '@components/ScrollView'; @@ -47,8 +48,29 @@ type SummaryItem = { testID?: string; }; -/** One row of a group. A single kind for now, so groups can grow other row kinds without touching their callers. */ -type SummaryGroupRow = SummaryItem; +/** A row for one entry of a repeated group, such as an owner, led by an avatar */ +type SummaryAvatarRow = { + /** Tells the row apart from an answer row */ + kind: 'avatar'; + + /** Unique key of the row */ + id: string; + + /** The entry's name */ + title: string; + + /** The line under the name */ + description: string; + + /** The avatar shown on the left */ + avatarSource?: string; + + /** Called when the row is pressed */ + onPress: () => void; +}; + +/** One row of a group: an answer, or an entry of a repeated group */ +type SummaryGroupRow = SummaryItem | SummaryAvatarRow; /** Answers under one heading. Used instead of `summaryItems` when the confirmation is split by section. */ type SummaryGroup = { @@ -67,7 +89,7 @@ type ConfirmationStepProps = SubPageProps & pageTitle: string; summaryItems?: SummaryItem[]; - /** Sections with their own heading, each holding field rows */ + /** Sections with their own heading, each holding field rows and avatar rows for repeated entries */ groups?: SummaryGroup[]; /** Whether show additional section with Onfido terms etc. */ @@ -133,20 +155,37 @@ function ConfirmationStep({ {group.name} )} - {group.rows.map(({id, description, title, shouldShowRightIcon, onPress, interactive, brickRoadIndicator, errorText, testID}) => ( - - ))} + {group.rows.map((row) => + 'kind' in row ? ( + // MenuItemAvatarNavigation takes no FullStory class, so the mask goes on a View around it + + + + ) : ( + + ), + )} ))} @@ -179,4 +218,4 @@ function ConfirmationStep({ } export default ConfirmationStep; -export type {SummaryGroup}; +export type {SummaryGroup, SummaryGroupRow}; diff --git a/src/languages/de.ts b/src/languages/de.ts index b4a17862a32e..d5c32ccf28d3 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3990,6 +3990,10 @@ ${amount} für ${merchant} – ${date}`, unlockAlreadyRequestedTitle: 'Anfrage bereits eingereicht', unlockAlreadyRequestedDescription: 'Ihre Anfrage zur Entsperrung dieses Bankkontos wurde bereits gesendet. Concierge meldet sich bei Ihnen, falls noch etwas benötigt wird.', }, + listField: { + editItem: ({name}: {name: string}) => `${name} bearbeiten`, + removeItem: ({name}: {name: string}) => `${name} entfernen`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Beispiel: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4001,7 +4005,12 @@ ${amount} für ${merchant} – ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Ungültiges Format. Beispiel: ${example}` : 'Ungültiges Format'), invalidOption: 'Wähle eine der verfügbaren Optionen', outOfRange: ({min, max}: {min: number; max: number}) => `Gib einen Wert zwischen ${min} und ${max} ein`, + tooFewItems: ({min}: {min: number}) => `Füge mindestens ${min} hinzu`, + tooManyItems: ({max}: {max: number}) => `Füge höchstens ${max} hinzu`, }, + addItem: ({item}: {item: string}) => `${item} hinzufügen`, + removeItemTitle: ({item}: {item: string}) => `${item} entfernen?`, + removeItemPrompt: ({name}: {name: string}) => `Möchtest du ${name} wirklich entfernen?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Der SWIFT/BIC muss 8 oder 11 Zeichen lang sein, mit 6 Buchstaben gefolgt von 2 oder 5 Buchstaben oder Ziffern.', diff --git a/src/languages/el.ts b/src/languages/el.ts index 83a2b4ceca05..cad02262aad0 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4038,6 +4038,10 @@ ${amount} για ${merchant} - ${date}`, unlockAlreadyRequestedDescription: 'Το αίτημά σας για ξεκλείδωμα αυτού του τραπεζικού λογαριασμού έχει ήδη αποσταλεί. Το Concierge θα επικοινωνήσει μαζί σας αν χρειαστεί οτιδήποτε άλλο.', }, + listField: { + editItem: ({name}: {name: string}) => `Επεξεργασία ${name}`, + removeItem: ({name}: {name: string}) => `Αφαίρεση ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Παράδειγμα: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4049,7 +4053,12 @@ ${amount} για ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Μη έγκυρη μορφή. Παράδειγμα: ${example}` : 'Μη έγκυρη μορφή'), invalidOption: 'Επιλέξτε μία από τις διαθέσιμες επιλογές', outOfRange: ({min, max}: {min: number; max: number}) => `Εισαγάγετε μια τιμή μεταξύ ${min} και ${max}`, + tooFewItems: ({min}: {min: number}) => `Προσθέστε τουλάχιστον ${min}`, + tooManyItems: ({max}: {max: number}) => `Προσθέστε το πολύ ${max}`, }, + addItem: ({item}: {item: string}) => `Προσθήκη ${item}`, + removeItemTitle: ({item}: {item: string}) => `Αφαίρεση ${item};`, + removeItemPrompt: ({name}: {name: string}) => `Θέλετε σίγουρα να αφαιρέσετε ${name};`, }, addPersonalBankAccount: { swiftBicFormatError: 'Το SWIFT/BIC πρέπει να έχει μήκος 8 ή 11 χαρακτήρες, με 6 γράμματα ακολουθούμενα από 2 ή 5 γράμματα ή αριθμούς.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 8838db29e77e..c55ca34289d4 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4155,6 +4155,10 @@ const translations = { sameDepositAndWithdrawalAccount: 'The deposit and withdrawal accounts are the same.', }, }, + listField: { + editItem: ({name}: {name: string}) => `Edit ${name}`, + removeItem: ({name}: {name: string}) => `Remove ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Example: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4166,7 +4170,12 @@ const translations = { invalidFormat: ({example}: {example?: string}) => (example ? `Invalid format. Example: ${example}` : 'Invalid format'), invalidOption: 'Choose one of the available options', outOfRange: ({min, max}: {min: number; max: number}) => `Enter a value between ${min} and ${max}`, + tooFewItems: ({min}: {min: number}) => `Add at least ${min}`, + tooManyItems: ({max}: {max: number}) => `Add at most ${max}`, }, + addItem: ({item}: {item: string}) => `Add ${item}`, + removeItemTitle: ({item}: {item: string}) => `Remove ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Are you sure you want to remove ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC must be 8 or 11 characters long, with 6 letters followed by 2 or 5 letters or numbers.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 66400f4418b7..4613ee8c8aaf 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3901,6 +3901,10 @@ ${amount} para ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Solicitud ya enviada', unlockAlreadyRequestedDescription: 'Tu solicitud para desbloquear esta cuenta bancaria ya ha sido enviada. Concierge se pondrá en contacto contigo si se necesita algo más.', }, + listField: { + editItem: ({name}: {name: string}) => `Editar ${name}`, + removeItem: ({name}: {name: string}) => `Eliminar ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Ejemplo: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3912,7 +3916,12 @@ ${amount} para ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Formato inválido. Ejemplo: ${example}` : 'Formato inválido'), invalidOption: 'Elige una de las opciones disponibles', outOfRange: ({min, max}: {min: number; max: number}) => `Introduce un valor entre ${min} y ${max}`, + tooFewItems: ({min}: {min: number}) => `Añade al menos ${min}`, + tooManyItems: ({max}: {max: number}) => `Añade como máximo ${max}`, }, + addItem: ({item}: {item: string}) => `Añadir ${item}`, + removeItemTitle: ({item}: {item: string}) => `¿Eliminar ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `¿Seguro que quieres eliminar a ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'El SWIFT/BIC debe tener 8 u 11 caracteres, con 6 letras seguidas de 2 o 5 letras o números.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 71a9a044e6c9..fb711d6b9947 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3997,6 +3997,10 @@ ${amount} pour ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Demande déjà soumise', unlockAlreadyRequestedDescription: 'Votre demande de déverrouillage de ce compte bancaire a déjà été envoyée. Concierge vous contactera si autre chose est nécessaire.', }, + listField: { + editItem: ({name}: {name: string}) => `Modifier ${name}`, + removeItem: ({name}: {name: string}) => `Supprimer ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Exemple : ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4008,7 +4012,12 @@ ${amount} pour ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Format invalide. Exemple : ${example}` : 'Format invalide'), invalidOption: "Choisissez l'une des options disponibles", outOfRange: ({min, max}: {min: number; max: number}) => `Saisissez une valeur entre ${min} et ${max}`, + tooFewItems: ({min}: {min: number}) => `Ajoutez au moins ${min}`, + tooManyItems: ({max}: {max: number}) => `Ajoutez au plus ${max}`, }, + addItem: ({item}: {item: string}) => `Ajouter ${item}`, + removeItemTitle: ({item}: {item: string}) => `Supprimer ${item} ?`, + removeItemPrompt: ({name}: {name: string}) => `Voulez-vous vraiment supprimer ${name} ?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Le code SWIFT/BIC doit comporter 8 ou 11 caractères, avec 6 lettres suivies de 2 ou 5 lettres ou chiffres.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 3bacfb1da4db..323e225f70d0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3968,6 +3968,10 @@ ${amount} per ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Richiesta già inviata', unlockAlreadyRequestedDescription: 'La tua richiesta di sblocco di questo conto bancario è già stata inviata. Concierge ti contatterà se servirà altro.', }, + listField: { + editItem: ({name}: {name: string}) => `Modifica ${name}`, + removeItem: ({name}: {name: string}) => `Rimuovi ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Esempio: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3979,7 +3983,12 @@ ${amount} per ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Formato non valido. Esempio: ${example}` : 'Formato non valido'), invalidOption: 'Scegli una delle opzioni disponibili', outOfRange: ({min, max}: {min: number; max: number}) => `Inserisci un valore tra ${min} e ${max}`, + tooFewItems: ({min}: {min: number}) => `Aggiungi almeno ${min}`, + tooManyItems: ({max}: {max: number}) => `Aggiungi al massimo ${max}`, }, + addItem: ({item}: {item: string}) => `Aggiungi ${item}`, + removeItemTitle: ({item}: {item: string}) => `Rimuovere ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Vuoi davvero rimuovere ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Lo SWIFT/BIC deve essere lungo 8 o 11 caratteri, con 6 lettere seguite da 2 oppure 5 lettere o numeri.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1cce695164c0..78886f6d6f77 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3937,6 +3937,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' unlockAlreadyRequestedTitle: 'リクエストは既に送信されています', unlockAlreadyRequestedDescription: 'この銀行口座のロック解除リクエストは既に送信されています。追加で必要なことがある場合は、Concierge からご連絡します。', }, + listField: { + editItem: ({name}: {name: string}) => `${name}を編集`, + removeItem: ({name}: {name: string}) => `${name}を削除`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `例:${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3948,7 +3952,12 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' invalidFormat: ({example}: {example?: string}) => (example ? `形式が正しくありません。例:${example}` : '形式が正しくありません'), invalidOption: '利用可能な選択肢から選んでください', outOfRange: ({min, max}: {min: number; max: number}) => `${min}から${max}の間の値を入力してください`, + tooFewItems: ({min}: {min: number}) => `${min}件以上追加してください`, + tooManyItems: ({max}: {max: number}) => `${max}件以内で追加してください`, }, + addItem: ({item}: {item: string}) => `${item}を追加`, + removeItemTitle: ({item}: {item: string}) => `${item}を削除しますか?`, + removeItemPrompt: ({name}: {name: string}) => `本当に${name}を削除しますか?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC は 8 文字または 11 文字で、最初の 6 文字はアルファベット、続く 2 文字または 5 文字はアルファベットまたは数字である必要があります。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 837344c66576..7b19a3af677d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3970,6 +3970,10 @@ ${amount} voor ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Verzoek al ingediend', unlockAlreadyRequestedDescription: 'Je verzoek om deze bankrekening te deblokkeren is al verzonden. Concierge neemt contact met je op als er nog iets anders nodig is.', }, + listField: { + editItem: ({name}: {name: string}) => `${name} bewerken`, + removeItem: ({name}: {name: string}) => `${name} verwijderen`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Voorbeeld: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3981,7 +3985,12 @@ ${amount} voor ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Ongeldige indeling. Voorbeeld: ${example}` : 'Ongeldige indeling'), invalidOption: 'Kies een van de beschikbare opties', outOfRange: ({min, max}: {min: number; max: number}) => `Voer een waarde in tussen ${min} en ${max}`, + tooFewItems: ({min}: {min: number}) => `Voeg er minimaal ${min} toe`, + tooManyItems: ({max}: {max: number}) => `Voeg er maximaal ${max} toe`, }, + addItem: ({item}: {item: string}) => `${item} toevoegen`, + removeItemTitle: ({item}: {item: string}) => `${item} verwijderen?`, + removeItemPrompt: ({name}: {name: string}) => `Weet je zeker dat je ${name} wilt verwijderen?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC moet 8 of 11 tekens lang zijn, met 6 letters gevolgd door 2 of 5 letters of cijfers.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0729b77939fb..f3b61a44f913 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4000,6 +4000,10 @@ ${amount} dla ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Wniosek został już złożony', unlockAlreadyRequestedDescription: 'Twoja prośba o odblokowanie tego konta bankowego została już wysłana. Concierge skontaktuje się z tobą, jeśli będzie potrzebne coś jeszcze.', }, + listField: { + editItem: ({name}: {name: string}) => `Edytuj ${name}`, + removeItem: ({name}: {name: string}) => `Usuń ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Przykład: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -4013,7 +4017,12 @@ ${amount} dla ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Nieprawidłowy format. Przykład: ${example}` : 'Nieprawidłowy format'), invalidOption: 'Wybierz jedną z dostępnych opcji', outOfRange: ({min, max}: {min: number; max: number}) => `Wprowadź wartość od ${min} do ${max}`, + tooFewItems: ({min}: {min: number}) => `Dodaj co najmniej ${min}`, + tooManyItems: ({max}: {max: number}) => `Dodaj maksymalnie ${max}`, }, + addItem: ({item}: {item: string}) => `Dodaj ${item}`, + removeItemTitle: ({item}: {item: string}) => `Usunąć ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Czy na pewno chcesz usunąć ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'Kod SWIFT/BIC musi mieć długość 8 lub 11 znaków, z 6 literami, po których następują 2 lub 5 liter lub cyfr.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 0ccd6cd242ac..9b41842f9f89 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3957,6 +3957,10 @@ ${amount} para ${merchant} - ${date}`, unlockAlreadyRequestedTitle: 'Solicitação já enviada', unlockAlreadyRequestedDescription: 'Sua solicitação para desbloquear esta conta bancária já foi enviada. O Concierge vai entrar em contato se for necessário mais alguma coisa.', }, + listField: { + editItem: ({name}: {name: string}) => `Editar ${name}`, + removeItem: ({name}: {name: string}) => `Remover ${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `Exemplo: ${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3968,7 +3972,12 @@ ${amount} para ${merchant} - ${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `Formato inválido. Exemplo: ${example}` : 'Formato inválido'), invalidOption: 'Escolha uma das opções disponíveis', outOfRange: ({min, max}: {min: number; max: number}) => `Insira um valor entre ${min} e ${max}`, + tooFewItems: ({min}: {min: number}) => `Adicione pelo menos ${min}`, + tooManyItems: ({max}: {max: number}) => `Adicione no máximo ${max}`, }, + addItem: ({item}: {item: string}) => `Adicionar ${item}`, + removeItemTitle: ({item}: {item: string}) => `Remover ${item}?`, + removeItemPrompt: ({name}: {name: string}) => `Tem certeza de que deseja remover ${name}?`, }, addPersonalBankAccount: { swiftBicFormatError: 'O SWIFT/BIC deve ter 8 ou 11 caracteres, com 6 letras seguidas de 2 ou 5 letras ou números.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index a2e4e693b885..78f4a4cdc330 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3846,6 +3846,10 @@ ${amount},商户:${merchant} - 日期:${date}`, unlockAlreadyRequestedTitle: '请求已提交', unlockAlreadyRequestedDescription: '您解锁此银行账户的请求已发送。如需其他信息,Concierge 会与您联系。', }, + listField: { + editItem: ({name}: {name: string}) => `编辑${name}`, + removeItem: ({name}: {name: string}) => `移除${name}`, + }, dynamicForm: { exampleHint: ({example}: {example: string}) => `示例:${example}`, fileCount: ({count}: {count: number}) => ({ @@ -3857,7 +3861,12 @@ ${amount},商户:${merchant} - 日期:${date}`, invalidFormat: ({example}: {example?: string}) => (example ? `格式无效。示例:${example}` : '格式无效'), invalidOption: '请从可用选项中选择', outOfRange: ({min, max}: {min: number; max: number}) => `请输入 ${min} 到 ${max} 之间的值`, + tooFewItems: ({min}: {min: number}) => `至少添加 ${min} 项`, + tooManyItems: ({max}: {max: number}) => `最多添加 ${max} 项`, }, + addItem: ({item}: {item: string}) => `添加${item}`, + removeItemTitle: ({item}: {item: string}) => `移除${item}?`, + removeItemPrompt: ({name}: {name: string}) => `确定要移除${name}吗?`, }, addPersonalBankAccount: { swiftBicFormatError: 'SWIFT/BIC 必须为 8 或 11 个字符长度,由 6 个字母后接 2 或 5 个字母或数字组成。', diff --git a/src/libs/UserAvatarUtils.ts b/src/libs/UserAvatarUtils.ts index 370a0370fed7..87b51d8d30c3 100644 --- a/src/libs/UserAvatarUtils.ts +++ b/src/libs/UserAvatarUtils.ts @@ -544,6 +544,13 @@ function optimizeAvatarSource(source?: AvatarSource): AvatarSource | undefined { return findLocalAvatarForURL(source) ?? source; } +/** Letter avatar for a free-text name, such as a list row title, seeded by the name so the color is stable */ +function getLetterAvatarURLForName(name: string): string | undefined { + const [firstName = '', ...otherNames] = name.trim().split(/\s+/); + const colorSeed = [...name].reduce((sum, character) => sum + character.charCodeAt(0), 0); + return getLetterAvatarURL(colorSeed, firstName, otherNames.at(-1) ?? '', '') || undefined; +} + export { buildUserIcon, getAccountIDFromAvatarID, @@ -557,6 +564,7 @@ export { getSmallSizeAvatar, getLetterAvatarInitials, getLetterAvatarURL, + getLetterAvatarURLForName, getUpdatedLetterAvatarURL, parseLetterAvatarURL, isCatalogAvatar, diff --git a/src/libs/actions/DynamicForm.ts b/src/libs/actions/DynamicForm.ts index ded7b0030193..a328c508cbd1 100644 --- a/src/libs/actions/DynamicForm.ts +++ b/src/libs/actions/DynamicForm.ts @@ -15,9 +15,24 @@ function saveSensitiveAnswers(formID: OnyxFormKey, answers: DynamicFormValues) { Onyx.merge(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[formID]: answers}); } +/** Drops some sensitive answers, such as those of a list entry the user removed */ +function forgetSensitiveAnswers(formID: OnyxFormKey, keys: string[]) { + Onyx.merge(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[formID]: Object.fromEntries(keys.map((key) => [key, null]))}); +} + +/** Fills the list entry editor with the entry's answers, or empties it for a new entry */ +function startListItemEdit(answers: DynamicFormValues): Promise { + return Onyx.set(ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT, answers); +} + +/** Empties the list entry editor once it closes, so an entry's answers are not left in its draft */ +function clearListItemEdit() { + Onyx.set(ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT, null); +} + /** Call once the form is submitted or abandoned, so sensitive answers do not outlive the visit */ function clearSensitiveAnswers(formID: OnyxFormKey) { Onyx.merge(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[formID]: null}); } -export {clearSensitiveAnswers, saveDraftAnswers, saveSensitiveAnswers}; +export {clearListItemEdit, clearSensitiveAnswers, forgetSensitiveAnswers, saveDraftAnswers, saveSensitiveAnswers, startListItemEdit}; diff --git a/src/stories/DynamicForm.stories.tsx b/src/stories/DynamicForm.stories.tsx new file mode 100644 index 000000000000..e9c170793bce --- /dev/null +++ b/src/stories/DynamicForm.stories.tsx @@ -0,0 +1,545 @@ +import DynamicFormFlow from '@components/DynamicForm'; +import DynamicFormFields from '@components/DynamicForm/components/DynamicFormFields'; +import type {DynamicFormLayout} from '@components/DynamicForm/types'; +import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import getPageTitle from '@components/DynamicForm/utils/getPageTitle'; +import {getAllFields, getPages} from '@components/DynamicForm/utils/paginate'; +import FormProvider from '@components/Form/FormProvider'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; + +import {clearDraftValues, setDraftValues} from '@userActions/FormActions'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import styles from '@src/styles'; +import {defaultTheme} from '@src/styles/theme'; +import type {DynamicFormListItem, DynamicFormPage, DynamicFormSchema, DynamicFormSchemaField} from '@src/types/onyx'; + +import type {Meta, StoryFn} from 'storybook-react-rsbuild'; + +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; +import {useArgs} from 'storybook/preview-api'; + +import {StoryRouterProvider} from '../../.storybook/mocks/storyRouter'; + +const defaultStyles = styles(defaultTheme); + +const STORYBOOK_FORM_ID = ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM; + +const FLOW_ROUTE_PREFIX = 'settings/wallet/add-bank-account'; + +/** A fixture field with the title of the page it is asked on */ +type GroupedField = DynamicFormSchemaField & {group: string}; + +/** Each run of fields sharing a group becomes one page titled by it, as the server sends pages */ +function toSchema(fields: GroupedField[]): DynamicFormSchema { + const pages: DynamicFormPage[] = []; + for (const {group, ...field} of fields) { + const lastPage = pages.at(-1); + if (lastPage?.title === group) { + lastPage.content.push(field); + continue; + } + pages.push({key: group, title: group, content: [field]}); + } + return {pages}; +} + +type Preset = { + /** Fields in page order, each naming its page in `group` */ + fields: GroupedField[]; + + /** Answers seeded into the form draft before the first render */ + draftValues: Record; +}; + +/** A bank account form spread over several pages */ +const originalBankAccountFields: GroupedField[] = [ + { + key: 'accountNumber', + label: 'Account number', + labelKey: 'bankAccount.accountNumber', + group: 'Account details', + type: 'text', + required: true, + regex: '^\\d{8}$', + minLength: 8, + maxLength: 8, + example: '12345678', + }, + { + key: 'legalType', + label: 'Recipient type', + group: 'Account details', + type: 'radio', + required: true, + values: [ + {key: 'PRIVATE', label: 'Person'}, + {key: 'BUSINESS', label: 'Business'}, + ], + refreshOnChange: true, + }, + { + key: 'accountType', + label: 'Account type', + group: 'Account details', + type: 'select', + required: true, + dependsOn: { + key: 'legalType', + valuesBy: { + PRIVATE: [ + {key: 'CHECKING', label: 'Checking'}, + {key: 'SAVINGS', label: 'Savings'}, + ], + BUSINESS: [ + {key: 'CHECKING', label: 'Checking'}, + {key: 'SAVINGS', label: 'Savings'}, + {key: 'BUSINESS_CHECKING', label: 'Business checking'}, + ], + }, + }, + }, + { + key: 'businessRegistrationDocument', + label: 'Business registration document', + group: 'Account details', + type: 'file', + required: true, + maxFiles: 2, + showWhen: {key: 'legalType', equals: ['BUSINESS']}, + }, + {key: 'annualVolume', label: 'Annual volume', group: 'Account details', type: 'amount', required: true, currencyKey: 'annualVolumeCurrency'}, + {key: 'numberOfEmployees', label: 'Number of employees', group: 'Account details', type: 'number', required: true, example: '25'}, + {key: 'settlementCurrency', label: 'Settlement currency', group: 'Account details', type: 'currency', required: true}, + {key: 'operatingCountries', label: 'Countries you operate in', group: 'Account holder details', type: 'countryMultiselect', required: true}, + {key: 'dateOfBirth', label: 'Date of birth', labelKey: 'common.dob', group: 'Account holder details', type: 'date', rule: 'dateOfBirth', required: true}, + {key: 'country', label: 'Country', labelKey: 'common.country', group: 'Account holder details', type: 'country', required: true, refreshOnChange: true}, + {key: 'address', label: 'Address', labelKey: 'common.address', group: 'Account holder details', type: 'address', required: true}, + { + key: 'useCases', + label: 'How will you use this account?', + group: 'Account holder details', + type: 'multiselect', + required: true, + values: [ + {key: 'PAYING_SUPPLIERS_CONTRACTORS_EMPLOYEES', label: 'Paying suppliers, contractors or employees'}, + {key: 'RECEIVING_MONEY_FOR_SERVICES', label: 'Receiving money for services'}, + {key: 'TRANSFER_WITHIN_COMPANY_OR_GROUP', label: 'Transfers within the company or group'}, + ], + }, + {key: 'isSourceOfFund', label: 'This source funds the account', group: 'Account holder details', type: 'boolean', required: false}, +]; + +const accountDetails: GroupedField[] = [ + {key: 'accountNumber', label: 'Account number', group: 'Account details', type: 'text', required: true, regex: '^\\d{8}$', minLength: 8, maxLength: 8, example: '12345678'}, + { + key: 'legalType', + label: 'Recipient type', + group: 'Account details', + type: 'radio', + required: true, + presentation: 'tabs', + values: [ + {key: 'PRIVATE', label: 'Person'}, + {key: 'BUSINESS', label: 'Business'}, + ], + }, + { + key: 'accountType', + label: 'Account type', + group: 'Account details', + type: 'select', + required: true, + dependsOn: { + key: 'legalType', + valuesBy: { + PRIVATE: [ + {key: 'CHECKING', label: 'Checking'}, + {key: 'SAVINGS', label: 'Savings'}, + ], + BUSINESS: [ + {key: 'CHECKING', label: 'Checking'}, + {key: 'BUSINESS_CHECKING', label: 'Business checking'}, + ], + }, + }, + }, + { + key: 'registrationDocument', + label: 'Business registration document', + group: 'Account details', + type: 'file', + required: true, + maxFiles: 2, + showWhen: {key: 'legalType', equals: ['BUSINESS']}, + }, + {key: 'annualVolume', label: 'Annual volume', group: 'Account details', type: 'amount', required: true, currencyKey: 'annualVolumeCurrency'}, + {key: 'numberOfEmployees', label: 'Number of employees', group: 'Account details', type: 'number', required: true, example: '25'}, + {key: 'settlementCurrency', label: 'Settlement currency', group: 'Account details', type: 'currency', required: true}, +]; + +const accountHolder: GroupedField[] = [ + {key: 'legalName', label: 'Legal name', group: 'Account holder', type: 'text', required: true, rule: 'legalName'}, + {key: 'phone', label: 'Phone number', group: 'Account holder', type: 'text', required: true, rule: 'phone'}, + {key: 'dateOfBirth', label: 'Date of birth', group: 'Account holder', type: 'date', required: true, rule: 'dateOfBirth'}, + { + key: 'ssn', + label: 'Social security number or ITIN', + group: 'Account holder', + type: 'text', + required: true, + sensitive: true, + keyboard: 'numeric', + regex: '^\\d{9}$', + example: '123456789', + }, + {key: 'address', label: 'Address', group: 'Account holder', type: 'address', required: true, rule: 'zipCode'}, + {key: 'operatingCountries', label: 'Countries you operate in', group: 'Account holder', type: 'countryMultiselect', required: true}, + {key: 'ownershipPercentage', label: 'Your ownership percentage', group: 'Account holder', type: 'percent', required: true}, + {key: 'acceptTerms', label: 'I confirm the details above are correct', group: 'Account holder', type: 'boolean', required: true}, +]; + +const businessInfo: GroupedField[] = [ + {key: 'legalBusinessName', label: 'Legal business name', group: 'Business info', type: 'text', required: true, readonly: true}, + { + key: 'companyRole', + label: 'Your role at the company', + group: 'Business info', + type: 'select', + required: true, + values: [ + {key: 'OWNER', label: 'Owner'}, + {key: 'DIRECTOR', label: 'Director'}, + {key: 'OTHER', label: 'Other'}, + ], + }, + { + key: 'industries', + label: 'Industry category', + group: 'Business info', + type: 'multiselect', + required: true, + values: [ + {key: 'SOFTWARE', label: 'Software'}, + {key: 'RETAIL', label: 'Retail'}, + {key: 'CRYPTO', label: 'Crypto (high risk)'}, + {key: 'GAMBLING', label: 'Gambling (high risk)'}, + ], + }, + {key: 'businessDescription', label: 'Business description', group: 'Business info', type: 'text', required: true, multiline: true, maxLength: 500}, +]; + +/** The design doc's enhanced due diligence: a source of funds asked only for high-risk industries, then a proof for it, each on its own page */ +const dueDiligence: GroupedField[] = [ + { + key: 'sourceOfFunds', + label: 'Source of funds', + group: 'Source of funds', + type: 'select', + required: true, + showWhen: {key: 'industries', equals: ['CRYPTO', 'GAMBLING']}, + values: [ + {key: 'SALES', label: 'Sales revenue'}, + {key: 'INVESTMENT', label: 'Investment'}, + {key: 'LOAN', label: 'Loan'}, + ], + }, + {key: 'sourceOfFundsProof', label: 'Source of funds document', group: 'Proof', type: 'file', required: true, showWhen: {key: 'sourceOfFunds', equals: ['SALES', 'INVESTMENT', 'LOAN']}}, +]; + +const owners: GroupedField = { + key: 'owners', + label: 'Owners', + itemLabel: 'owner', + addItemDescription: 'Only applies if 25% ownership or more', + group: 'Owners', + type: 'list', + required: true, + minItems: 1, + maxItems: 4, + itemFields: [ + { + key: 'ownerType', + label: 'Owner type', + type: 'radio', + presentation: 'tabs', + required: true, + values: [ + {key: 'INDIVIDUAL', label: 'Individual'}, + {key: 'COMPANY', label: 'Company/Fund'}, + ], + }, + {key: 'firstName', label: 'Legal first name', type: 'text', required: true, rule: 'legalName', showWhen: {key: 'ownerType', equals: ['INDIVIDUAL']}}, + {key: 'lastName', label: 'Legal last name', type: 'text', required: true, rule: 'legalName', showWhen: {key: 'ownerType', equals: ['INDIVIDUAL']}}, + {key: 'ssn', label: 'SSN', type: 'text', required: false, sensitive: true, showWhen: {key: 'ownerType', equals: ['INDIVIDUAL']}}, + {key: 'companyName', label: 'Legal business name', type: 'text', required: true, showWhen: {key: 'ownerType', equals: ['COMPANY']}}, + {key: 'countryOfResidence', label: 'Country of residence', type: 'country', required: true}, + {key: 'ownership', label: 'Ownership percentage', type: 'percent', required: true}, + ], +}; + +/** Every case Storybook shows, each usable in the Flow, the Playground and as its own sidebar entry */ +const PRESETS = { + originalBankAccount: { + fields: originalBankAccountFields, + draftValues: { + numberOfEmployees: '25', + settlementCurrency: 'USD', + operatingCountries: ['GB'], + legalType: 'PRIVATE', + accountNumber: '12345678', + accountType: 'CHECKING', + annualVolume: '1000', + annualVolumeCurrency: 'USD', + dateOfBirth: '1990-01-31', + country: 'GB', + address: '1 High Street', + }, + }, + bankAccount: { + fields: [...accountDetails, ...accountHolder], + draftValues: {legalType: 'PRIVATE', numberOfEmployees: '25'}, + }, + businessOnboarding: { + fields: [...businessInfo, ...dueDiligence, owners], + draftValues: {legalBusinessName: 'Boulder Development Inc.'}, + }, + owners: { + fields: [owners], + draftValues: { + owners: [ + {id: 'alice', ownerType: 'INDIVIDUAL', firstName: 'Alice', lastName: 'Nguyen', countryOfResidence: 'US', ownership: '25'}, + {id: 'acme', ownerType: 'COMPANY', companyName: 'Acme Holdings', countryOfResidence: 'GB', ownership: '40'}, + ], + }, + }, + loneMultiselect: { + fields: businessInfo.filter((field) => field.key === 'industries'), + draftValues: {}, + }, + yesNoQuestion: { + fields: [{key: 'hasOtherOwners', label: 'Does anyone else own 25% or more?', group: 'Ownership', type: 'boolean', required: true}], + draftValues: {}, + }, + loneCountry: { + fields: [{key: 'country', label: 'Where is your bank account?', group: 'Country', type: 'country', required: true}], + draftValues: {}, + }, + loneCurrency: { + fields: [{key: 'currency', label: 'Which currency?', group: 'Currency', type: 'currency', required: true}], + draftValues: {}, + }, + amountWithCurrency: { + fields: accountDetails.filter((field) => field.key === 'annualVolume' || field.key === 'accountNumber'), + draftValues: {annualVolume: '25000'}, + }, + unknownType: { + fields: [{key: 'signature', label: 'Signature', group: 'Sign', type: 'signature', required: true}, ...accountDetails.slice(0, 1).map((field) => ({...field, group: 'Sign'}))], + draftValues: {}, + }, +} satisfies Record; + +type PresetName = keyof typeof PRESETS; + +const PRESET_NAMES = Object.keys(PRESETS).filter((name): name is PresetName => name in PRESETS); + +/** Seeds the draft after mount and reports once the write has landed, so the form mounts with the draft as a real page would */ +function useSeededDraft(draftValues: Preset['draftValues']): boolean { + const [isSeeded, setIsSeeded] = useState(false); + useEffect(() => { + clearDraftValues(STORYBOOK_FORM_ID); + setDraftValues(STORYBOOK_FORM_ID, draftValues).then(() => setIsSeeded(true)); + }, [draftValues]); + return isSeeded; +} + +/** Every page on one screen, for looking at the renderer rather than the flow */ +function SinglePage({fields, draftValues}: Preset) { + const {translate} = useLocalize(); + const isDraftReady = useSeededDraft(draftValues); + // List entries are edited on flow pages, so this screen leaves lists out + const pages = getPages(toSchema(fields.filter((field) => field.type !== 'list'))); + const allFields = getAllFields(pages); + + if (!isDraftReady) { + return ; + } + + return ( + getDynamicFieldErrors(allFields, values, translate)} + onSubmit={(values) => alert(JSON.stringify(values, null, 4))} + style={defaultStyles.ph5} + > + {({inputValues}) => + pages.map((page) => ( + + {getPageTitle(page, inputValues, translate, allFields)} + + + )) + } + + ); +} + +type FlowProps = Preset & { + layout: DynamicFormLayout; + confirmation: 'auto' | 'yes' | 'no'; + + /** Remounts the route stack when it changes, so a new schema starts from its first page */ + resetKey: string; +}; + +/** The whole flow on a story-owned route stack: pages, resume, the entry editor pages and the confirmation are all clickable */ +function Flow({fields, draftValues, layout, confirmation, resetKey}: FlowProps) { + const isDraftReady = useSeededDraft(draftValues); + if (!isDraftReady) { + return ; + } + let hasConfirmation: boolean | undefined; + if (confirmation !== 'auto') { + hasConfirmation = confirmation === 'yes'; + } + return ( + + ROUTES.SETTINGS_ADD_BANK_ACCOUNT.getRoute(undefined, pageName, action)} + onSubmit={(values) => alert(JSON.stringify(values, null, 4))} + onBack={() => alert('Left the flow')} + onRefreshRequirements={(inputID, value) => console.debug('Refresh requirements', inputID, value)} + layout={layout} + hasConfirmation={hasConfirmation} + /> + + ); +} + +type FlowStoryProps = Pick & { + preset: PresetName; +}; + +function FlowFromPreset({preset, layout, confirmation}: FlowStoryProps) { + const {fields, draftValues} = PRESETS[preset]; + return ( + + ); +} + +const flowArgTypes = { + preset: {options: PRESET_NAMES, control: {type: 'select'}}, + layout: {options: ['auto', 'stepper', 'pages'], control: {type: 'radio'}}, + confirmation: {options: ['auto', 'yes', 'no'], control: {type: 'radio'}}, +} as const; + +function presetFlowStory(preset: PresetName): StoryFn { + const flowStory: StoryFn = FlowFromPreset.bind({}); + flowStory.args = {preset, layout: 'auto', confirmation: 'auto'}; + flowStory.argTypes = flowArgTypes; + return flowStory; +} + +const STORY_FRAME_INSET = '2rem'; + +const story: Meta = { + title: 'Components/DynamicForm', + component: FlowFromPreset, + decorators: [ + (Story) => ( + // Screens size to their window, so these stories get the viewport as their frame +
    + +
    + ), + ], +}; + +type PlaygroundProps = Preset & { + /** Loads a starting schema and draft into the editable `fields` and `draftValues` controls */ + preset: PresetName; + + /** The real flow, or every group on one screen */ + layout: 'flow' | 'single'; +}; + +/** Editable schema: pick a preset, then change the field JSON in the Controls panel and watch the form follow */ +function Playground({preset, fields, draftValues, layout}: PlaygroundProps) { + const [, updateArgs] = useArgs(); + useEffect(() => { + updateArgs(PRESETS[preset]); + }, [preset, updateArgs]); + + const schema = Array.isArray(fields) ? fields : []; + const resetKey = `${preset}-${JSON.stringify(draftValues)}-${JSON.stringify(schema)}`; + if (layout === 'single') { + return ( + + ); + } + return ( + + ); +} + +const FlowStory = presetFlowStory('originalBankAccount'); +FlowStory.storyName = 'Flow'; + +const PlaygroundStory: StoryFn = Playground.bind({}); +PlaygroundStory.storyName = 'Playground'; +PlaygroundStory.args = {preset: 'originalBankAccount', ...PRESETS.originalBankAccount, layout: 'flow'}; +PlaygroundStory.argTypes = { + preset: {options: PRESET_NAMES, control: {type: 'select'}}, + fields: {control: {type: 'object'}}, + draftValues: {control: {type: 'object'}}, + layout: {options: ['flow', 'single'], control: {type: 'radio'}}, +}; + +const AllFieldTypes: StoryFn = SinglePage.bind({}); +AllFieldTypes.args = {fields: [...accountDetails, ...accountHolder, ...businessInfo], draftValues: {legalType: 'BUSINESS', legalBusinessName: 'Boulder Development Inc.'}}; + +const BankAccount = presetFlowStory('bankAccount'); +const BusinessOnboarding = presetFlowStory('businessOnboarding'); +const OwnersList = presetFlowStory('owners'); +const LoneMultiselect = presetFlowStory('loneMultiselect'); +const YesNoQuestion = presetFlowStory('yesNoQuestion'); +const LoneCountry = presetFlowStory('loneCountry'); +const LoneCurrency = presetFlowStory('loneCurrency'); +const AmountWithCurrency = presetFlowStory('amountWithCurrency'); +const UnknownType = presetFlowStory('unknownType'); + +export default story; +export {FlowStory, PlaygroundStory, AllFieldTypes, BankAccount, BusinessOnboarding, OwnersList, LoneMultiselect, YesNoQuestion, LoneCountry, LoneCurrency, AmountWithCurrency, UnknownType}; diff --git a/src/types/form/DynamicFormListItemForm.ts b/src/types/form/DynamicFormListItemForm.ts new file mode 100644 index 000000000000..26fb0bcc45e0 --- /dev/null +++ b/src/types/form/DynamicFormListItemForm.ts @@ -0,0 +1,9 @@ +import type {FormValue} from '@components/Form/types'; + +import type {BaseForm} from './Form'; + +/** The list item being added or edited. Its keys are the item schema's field keys. */ +type DynamicFormListItemForm = BaseForm & Record; + +// eslint-disable-next-line import/prefer-default-export +export type {DynamicFormListItemForm}; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index dd91169079fe..cbeed7e04ae8 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -100,6 +100,7 @@ export type {MoneyRequestTimeForm} from './MoneyRequestTimeForm'; export type {MoneyRequestSubrateForm} from './MoneyRequestSubrateForm'; export type {CollectDepositAccountForm} from './CollectDepositAccountForm'; export type {InternationalBankAccountForm} from './InternationalBankAccountForm'; +export type {DynamicFormListItemForm} from './DynamicFormListItemForm'; export type {WorkspacePerDiemForm} from './WorkspacePerDiemForm'; export type {MoneyRequestRejectReasonForm} from './MoneyRequestRejectReasonForm'; export type {ReportRejectForm} from './ReportRejectForm'; diff --git a/src/types/onyx/DynamicFormField.ts b/src/types/onyx/DynamicFormField.ts index e2b62282bba2..6e1141ff8251 100644 --- a/src/types/onyx/DynamicFormField.ts +++ b/src/types/onyx/DynamicFormField.ts @@ -148,6 +148,27 @@ type DynamicFormBooleanField = DynamicFormFieldBase & { type: 'boolean'; }; +/** Repeated entries, such as a company's owners, each answered with its own fields on an editor page */ +type DynamicFormListField = DynamicFormFieldBase & { + type: 'list'; + + /** The fields of one entry */ + itemFields: DynamicFormField[]; + + /** The noun for one entry, such as "owner", for the add row and the editor title */ + itemLabel?: string; + + itemLabelKey?: TranslationPaths; + + /** Hint shown under the add row */ + addItemDescription?: string; + + addItemDescriptionKey?: TranslationPaths; + + minItems?: number; + maxItems?: number; +}; + /** Types that need nothing beyond the common properties */ type DynamicFormPlainField = DynamicFormFieldBase & { type: 'currency' | 'percent'; @@ -165,8 +186,15 @@ type DynamicFormField = | DynamicFormAmountField | DynamicFormCountryField | DynamicFormBooleanField + | DynamicFormListField | DynamicFormPlainField; +/** One entry of a list field: its answers keyed by item field key, and an `id` made on the device to key the entry's row and editor page */ +type DynamicFormListItem = { + id: string; + [fieldKey: string]: FormValue | undefined; +}; + /** A field as the schema sends it, which can be of a type this App version does not know */ type DynamicFormSchemaField = DynamicFormField | (DynamicFormFieldBase & {type: string}); @@ -197,6 +225,8 @@ export type { DynamicFormFieldOption, DynamicFormFieldType, DynamicFormHeading, + DynamicFormListField, + DynamicFormListItem, DynamicFormMultiChoiceField, DynamicFormNumberField, DynamicFormPage, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index a86e9d85adb5..95f6f7474ba5 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -91,6 +91,8 @@ import type { DynamicFormFieldOption, DynamicFormFieldType, DynamicFormHeading, + DynamicFormListField, + DynamicFormListItem, DynamicFormMultiChoiceField, DynamicFormNumberField, DynamicFormPage, @@ -478,6 +480,8 @@ export type { DynamicFormFieldOption, DynamicFormFieldType, DynamicFormHeading, + DynamicFormListField, + DynamicFormListItem, DynamicFormMultiChoiceField, DynamicFormNumberField, DynamicFormPage, diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx index eb27f45c4136..bbc7f0c922f5 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx @@ -9,11 +9,13 @@ import DatePicker from '@components/DatePicker'; import CurrencyInlineListAdapter from '@components/DynamicForm/adapters/CurrencyInlineListAdapter'; import FileUploadAdapter from '@components/DynamicForm/adapters/FileUploadAdapter'; import InlineSelectionListAdapter from '@components/DynamicForm/adapters/InlineSelectionListAdapter'; +import ListFieldAdapter from '@components/DynamicForm/adapters/ListFieldAdapter'; import TabsAdapter from '@components/DynamicForm/adapters/TabsAdapter'; import YesNoAdapter from '@components/DynamicForm/adapters/YesNoAdapter'; import DynamicFormFields from '@components/DynamicForm/components/DynamicFormFields'; import type {DynamicFormValues} from '@components/DynamicForm/types'; import getAddressInputKeys from '@components/DynamicForm/utils/getAddressInputKeys'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import PercentageForm from '@components/PercentageForm'; import PushRowWithModal from '@components/PushRowWithModal'; import RadioButtons from '@components/RadioButtons'; @@ -69,6 +71,20 @@ jest.mock('@hooks/useLocalize', () => jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +type TestInstance = ReturnType; + +/** Whether a view around the element masks it in session recordings */ +function isMasked(element: TestInstance): boolean { + let ancestor: TestInstance | null = element; + while (ancestor) { + if (ancestor.props.fsClass === CONST.FULLSTORY.CLASS.MASK) { + return true; + } + ancestor = ancestor.parent; + } + return false; +} + type RenderFieldsOptions = { currency?: string; maxFileSize?: number; @@ -107,6 +123,7 @@ const EXPECTED_INPUT_BY_TYPE: Record> file: FileUploadAdapter, amount: AmountForm, percent: PercentageForm, + list: ListFieldAdapter, }; const FIELD_TYPES = Object.keys(EXPECTED_INPUT_BY_TYPE).filter((type): type is DynamicFormFieldType => Object.hasOwn(EXPECTED_INPUT_BY_TYPE, type)); @@ -114,14 +131,15 @@ const FIELD_TYPES = Object.keys(EXPECTED_INPUT_BY_TYPE).filter((type): type is D describe('DynamicFormFields', () => { it.each(FIELD_TYPES)('renders a %s field with its input', (type) => { // Given a field of one type with nothing but the required properties, next to another question so it is drawn as a row - const field: DynamicFormField = {key: 'answer', type, required: false}; + const field: DynamicFormField = type === 'list' ? {key: 'answer', type, required: false, itemFields: []} : {key: 'answer', type, required: false}; // When it renders const rendered = renderFields([field, otherQuestion]); - // Then it is drawn by the input its type maps to, which is what a schema without layout hints gets, and its answer is masked in session recordings + // Then it is drawn by the input its type maps to, which is what a schema without layout hints gets, and its answer is masked in session recordings, including by inputs that do not pass the FullStory class on expect(rendered.get('answer')?.InputComponent).toBe(EXPECTED_INPUT_BY_TYPE[type]); expect(rendered.get('answer')?.forwardedFSClass).toBe(CONST.FULLSTORY.CLASS.MASK); + expect(isMasked(screen.UNSAFE_getByProps({inputID: 'answer'}))).toBe(true); }); it('keeps sensitive answers out of the draft', () => { @@ -232,9 +250,10 @@ describe('DynamicFormFields', () => { // When the fields render const rendered = renderFields([readonlyCountry], {nationality: 'GB'}); - // Then no input is drawn, and the row shows the translated country + // Then no input is drawn, and the row shows the translated country, masked in session recordings since a prefilled answer is still personal expect(rendered.size).toBe(0); expect(screen.getByText('allCountries.GB')).toBeOnTheScreen(); + expect(isMasked(screen.UNSAFE_getByType(MenuItemWithTopDescription))).toBe(true); }); it('draws a heading with its description above the fields that follow it', () => { diff --git a/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx index 5f44cc6016dc..2cca3cbbec1a 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx @@ -2,14 +2,17 @@ import {render} from '@testing-library/react-native'; import DynamicFormFlow from '@components/DynamicForm'; import type {DynamicFormLayout, DynamicFormSubPageProps} from '@components/DynamicForm/types'; +import {getListItems, getListItemSensitiveKey} from '@components/DynamicForm/utils/listItems'; +import type {FormValue} from '@components/Form/types'; import useSubPage from '@hooks/useSubPage'; import type {SubPageProps} from '@hooks/useSubPage/types'; import Navigation from '@libs/Navigation/Navigation'; -import {clearSensitiveAnswers, saveDraftAnswers, saveSensitiveAnswers} from '@userActions/DynamicForm'; +import {clearSensitiveAnswers, forgetSensitiveAnswers, saveDraftAnswers, saveSensitiveAnswers} from '@userActions/DynamicForm'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {DynamicFormPage, DynamicFormSchema} from '@src/types/onyx'; @@ -37,6 +40,7 @@ jest.mock('@userActions/DynamicForm', () => ({ saveDraftAnswers: jest.fn(), saveSensitiveAnswers: jest.fn(), clearSensitiveAnswers: jest.fn(), + forgetSensitiveAnswers: jest.fn(), })); jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn(), goBack: jest.fn()})); jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); @@ -49,8 +53,8 @@ jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => }), ); -// Any form key works, since the draft is read as plain values -const FORM_ID = ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM; +// Any form key works, since the draft is read as plain values. This one types its draft as any answers, list entries included. +const FORM_ID = ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM; const personalDetails: DynamicFormPage = { key: 'personal-details', @@ -78,8 +82,8 @@ const buildRoute = (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_PE type RenderFlowOptions = { flowSchema?: DynamicFormSchema; - draft?: Record; - sensitiveAnswers?: Record; + draft?: Record; + sensitiveAnswers?: Record; pageIndex?: number; currentPageName?: string; isRedirecting?: boolean; @@ -475,4 +479,154 @@ describe('DynamicFormFlow', () => { // Then the page gets it, so file fields can enforce it expect(pageProps?.maxFileSize).toBe(2097152); }); + + describe('with a list field', () => { + const directorsSchema: DynamicFormSchema = { + pages: [ + { + key: 'directors', + title: 'Directors', + content: [ + { + key: 'directors', + type: 'list', + required: true, + itemLabel: 'director', + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: false, sensitive: true}, + ], + }, + ], + }, + ], + }; + const jane = {id: 'jane', firstName: 'Jane'}; + + it('keeps the entry editors out of Next and Back', async () => { + // Given a list with one entry + // When the flow renders + const {subPageOptions} = await renderFlow({flowSchema: directorsSchema, draft: {directors: [jane]}, currentPageName: 'directors'}); + + // Then the editors of a new and of the existing entry are pages that only Add, Edit or a row can open + expect(subPageOptions.skipPages).toEqual(expect.arrayContaining(['directors~new', 'directors~jane'])); + }); + + it('opens the entry editor page when the user edits an entry', async () => { + // Given the list page with one entry + const {pageProps, subPageOptions} = await renderFlow({flowSchema: directorsSchema, draft: {directors: [jane]}, currentPageName: 'directors'}); + + // When the user edits the entry + pageProps?.onOpenListItemEditor('directors', 'jane'); + + // Then the entry's editor page opens, outside edit mode since the list page was not opened from the confirmation page + expect(mockMoveTo).toHaveBeenCalledWith( + subPageOptions.pages.findIndex((page) => page.pageName === 'directors~jane'), + false, + ); + }); + + it('drafts a new entry, keeps its SSN in memory, and returns to the list page', async () => { + // Given the editor of a new director + const {pageProps} = await renderFlow({flowSchema: directorsSchema, currentPageName: 'directors~new'}); + + // When the user saves it + pageProps?.onListItemSave('directors', undefined, {firstName: 'John', ssn: '123456789'}); + + // Then the entry is added to the draft without its SSN, the SSN is kept in memory under the entry, and the list page opens again + const draftedItems = getListItems(jest.mocked(saveDraftAnswers).mock.lastCall?.[1].directors); + expect(draftedItems.map((item) => item.firstName)).toEqual(['John']); + expect(draftedItems.at(0)?.ssn).toBeUndefined(); + expect(jest.mocked(saveSensitiveAnswers).mock.lastCall).toEqual([ + FORM_ID, + Object.fromEntries(draftedItems.map((item) => [getListItemSensitiveKey('directors', item.id, 'ssn'), '123456789'])), + ]); + expect(Navigation.goBack).toHaveBeenCalledWith(buildRoute('directors')); + }); + + it('forgets the SSN of a removed entry when the user leaves the list page', async () => { + // Given a director whose SSN is kept in memory, removed from the list on its page + const {pageProps} = await renderFlow({ + flowSchema: directorsSchema, + draft: {directors: [jane]}, + sensitiveAnswers: {[getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}, + currentPageName: 'directors', + }); + const page = pageProps?.pages.at(0); + if (!pageProps || !page) { + throw new Error('No list page'); + } + + // When the user leaves the page with Next + pageProps.onPageSubmit(page, {directors: []}); + + // Then the removed director's SSN is dropped from memory instead of lasting the whole visit + expect(forgetSensitiveAnswers).toHaveBeenCalledWith(FORM_ID, [getListItemSensitiveKey('directors', 'jane', 'ssn')]); + }); + + it('gives the screen the list entries when the user leaves the list page, without their SSNs', async () => { + // Given a director whose SSN is kept in memory, on the list page + const {pageProps, onPageSubmit} = await renderFlow({ + flowSchema: directorsSchema, + draft: {directors: [jane]}, + sensitiveAnswers: {[getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}, + currentPageName: 'directors', + }); + const page = pageProps?.pages.at(0); + if (!pageProps || !page) { + throw new Error('No list page'); + } + + // When the user leaves the page with Next + pageProps.onPageSubmit(page, {directors: [jane]}); + + // Then a screen saving page by page gets the director, while the SSN stays in memory, out of the request queue + expect(onPageSubmit).toHaveBeenCalledWith(page, {directors: [jane]}); + }); + + it('keeps a sensitive list in memory instead of the draft', async () => { + // Given the editor of a new director, in a list marked sensitive as a whole + const sensitiveSchema: DynamicFormSchema = { + pages: [{key: 'directors', content: [{key: 'directors', type: 'list', required: true, sensitive: true, itemFields: [{key: 'firstName', type: 'text', required: true}]}]}], + }; + const {pageProps} = await renderFlow({flowSchema: sensitiveSchema, currentPageName: 'directors~new'}); + jest.mocked(saveDraftAnswers).mockClear(); + + // When the user saves the director + pageProps?.onListItemSave('directors', undefined, {firstName: 'John'}); + + // Then the list goes to memory only, so no director reaches the device's storage + expect(saveDraftAnswers).not.toHaveBeenCalled(); + expect(getListItems(jest.mocked(saveSensitiveAnswers).mock.lastCall?.[1].directors).map((item) => item.firstName)).toEqual(['John']); + }); + + it('returns from an entry editor to its list page on back', async () => { + // Given the editor of an existing entry + const {onBack} = await renderFlow({flowSchema: directorsSchema, draft: {directors: [jane]}, currentPageName: 'directors~jane', pageIndex: 2}); + + // When the user presses back + mockOnBackButtonPress?.(); + + // Then the list page opens, rather than the page before the editor or the screen before the flow + expect(Navigation.goBack).toHaveBeenCalledWith(buildRoute('directors')); + expect(onBack).not.toHaveBeenCalled(); + }); + + it('submits each entry with its sensitive answers', async () => { + // Given a director whose SSN is kept in memory + const {subPageOptions, onSubmit} = await renderFlow({ + flowSchema: directorsSchema, + draft: {directors: [jane]}, + sensitiveAnswers: {[getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}, + currentPageName: CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY, + hasConfirmation: true, + }); + + // When the user confirms + subPageOptions.onFinished(); + + // Then the entry is submitted with its SSN back in place + expect(onSubmit).toHaveBeenCalledWith({directors: [{id: 'jane', firstName: 'Jane', ssn: '123456789'}]}); + }); + }); }); diff --git a/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx b/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx new file mode 100644 index 000000000000..51108c4b0ec4 --- /dev/null +++ b/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx @@ -0,0 +1,150 @@ +import {act, render} from '@testing-library/react-native'; + +import ListFieldAdapter from '@components/DynamicForm/adapters/ListFieldAdapter'; +import type {ListFieldRow} from '@components/ListField'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import {clearListItemEdit} from '@userActions/DynamicForm'; + +import type {DynamicFormField, DynamicFormListItem} from '@src/types/onyx'; + +import React from 'react'; + +type CapturedListFieldProps = { + rows: ListFieldRow[]; + canAddMore?: boolean; + onAdd: () => void; + onEdit: (id: string) => void; + onRemove: (id: string) => void; +}; + +type CapturedModalProps = { + isVisible: boolean; + keptAnswers: Record; + onSave: (answers: Record) => void; + onClose: () => void; +}; + +type ConfirmModalOptions = { + title: string; +}; + +const mockShowConfirmModal = jest.fn, [ConfirmModalOptions]>(() => Promise.resolve({action: ModalActions.CONFIRM})); + +const mockListField = jest.fn(() => null); +const mockModal = jest.fn(() => null); + +jest.mock('@components/ListField', () => ({ + __esModule: true, + default: (props: CapturedListFieldProps) => mockListField(props), +})); +jest.mock('@components/DynamicForm/components/DynamicFormListItemModal', () => ({ + __esModule: true, + default: (props: CapturedModalProps) => mockModal(props), +})); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: mockShowConfirmModal}))); +jest.mock('@userActions/DynamicForm', () => ({startListItemEdit: jest.fn(() => Promise.resolve()), clearListItemEdit: jest.fn()})); + +const itemFields: DynamicFormField[] = [{key: 'fullName', type: 'text', required: true}]; +const jane: DynamicFormListItem = {id: 'jane', fullName: 'Jane Doe'}; +const john: DynamicFormListItem = {id: 'john', fullName: 'John Roe'}; + +function renderAdapter(value: DynamicFormListItem[], onInputChange: (items: DynamicFormListItem[]) => void, maxItems?: number, isInFlow = true) { + render( + , + ); + const props = mockListField.mock.lastCall?.[0]; + if (!props) { + throw new Error('ListField did not render'); + } + return props; +} + +describe('ListFieldAdapter', () => { + it('shows each entry as a row named by its answers', () => { + // Given two directors + // When the list renders + const {rows} = renderAdapter([jane, john], jest.fn()); + + // Then each director is a row titled with their name, so the user can tell them apart + expect(rows.map((row) => [row.id, row.title])).toEqual([ + ['jane', 'Jane Doe'], + ['john', 'John Roe'], + ]); + }); + + it('gives FormProvider the list without the removed entry once the user confirms', async () => { + // Given two directors + const onInputChange = jest.fn(); + const {onRemove} = renderAdapter([jane, john], onInputChange); + + // When the user removes one and confirms + await act(async () => onRemove('jane')); + + // Then the form value is the whole list minus that director, and the unmasked modal title names no one, since the name is personal + expect(onInputChange).toHaveBeenCalledWith([john]); + expect(mockShowConfirmModal.mock.lastCall?.[0].title).not.toContain('Jane'); + }); + + it('keeps the entry when the user cancels the removal', async () => { + // Given two directors, and a user who will cancel the confirmation + const onInputChange = jest.fn(); + const {onRemove} = renderAdapter([jane, john], onInputChange); + mockShowConfirmModal.mockResolvedValueOnce({action: ModalActions.CLOSE}); + + // When the user taps remove on one + await act(async () => onRemove('jane')); + + // Then the list is left as it was + expect(onInputChange).not.toHaveBeenCalled(); + }); + + it('edits an entry in a modal outside the flow, keeping a stored sensitive answer the user leaves blank', async () => { + // Given a director with a stored SSN, in a list that no flow opens editor pages for + const onInputChange = jest.fn(); + const {onEdit} = renderAdapter([{...jane, ssn: '123456789'}], onInputChange, undefined, false); + + // When the user edits the director, renames them and saves with the SSN left blank + await act(async () => onEdit('jane')); + const modal = mockModal.mock.lastCall?.[0]; + act(() => modal?.onSave({fullName: 'Jane Smith', ...modal.keptAnswers})); + + // Then the modal opened over the page, and the list holds the renamed director with the SSN still in place + expect(modal?.isVisible).toBe(true); + expect(onInputChange).toHaveBeenCalledWith([{id: 'jane', fullName: 'Jane Smith', ssn: '123456789'}]); + }); + + it('empties the editor draft when the modal closes', async () => { + // Given a director open in the modal editor outside the flow + const {onEdit} = renderAdapter([jane], jest.fn(), undefined, false); + await act(async () => onEdit('jane')); + jest.mocked(clearListItemEdit).mockClear(); + + // When the user closes the modal without saving + act(() => mockModal.mock.lastCall?.[0].onClose()); + + // Then the editor draft is emptied, so the director's answers do not stay on the device + expect(clearListItemEdit).toHaveBeenCalledTimes(1); + }); + + it('stops offering to add entries once the list is full', () => { + // Given a list allowing two directors, with two + // When it renders + const {canAddMore} = renderAdapter([jane, john], jest.fn(), 2); + + // Then the add row is hidden, as a third director could not be submitted + expect(canAddMore).toBe(false); + }); +}); diff --git a/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts b/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts index 4a4585a2f71a..168e4d5d8354 100644 --- a/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts +++ b/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts @@ -22,7 +22,7 @@ describe('getConfirmationItems', () => { it('masks sensitive answers and opens the page of an editable row', () => { // Given answers with an SSN const onEditPage = jest.fn(); - const [group] = getConfirmationItems(pages, getAllFields(pages), {name: 'Ada', ssn: '123451234', country: 'US'}, translateLocal, onEditPage); + const [group] = getConfirmationItems(pages, getAllFields(pages), {name: 'Ada', ssn: '123451234', country: 'US'}, translateLocal, onEditPage, jest.fn()); // When the user taps the SSN row const ssnRow = group.rows.find((row) => row.id === 'ssn'); @@ -36,22 +36,73 @@ describe('getConfirmationItems', () => { it('shows a readonly row with no chevron and no action', () => { // Given a readonly field const onEditPage = jest.fn(); - const [group] = getConfirmationItems(pages, getAllFields(pages), {name: 'Ada', ssn: '123451234', country: 'US'}, translateLocal, onEditPage); + const [group] = getConfirmationItems(pages, getAllFields(pages), {name: 'Ada', ssn: '123451234', country: 'US'}, translateLocal, onEditPage, jest.fn()); // When its row is tapped const row = group.rows.find((candidate) => candidate.id === 'country'); row?.onPress(); // Then it is not editable - expect(row?.shouldShowRightIcon).toBe(false); + expect(row).toMatchObject({shouldShowRightIcon: false}); expect(onEditPage).not.toHaveBeenCalled(); }); it('has no group for a page that is not shown', () => { // Given a private recipient, so the business page is skipped - const groups = getConfirmationItems(pages, getAllFields(pages), {type: 'PRIVATE', name: 'Ada', ein: '1'}, translateLocal, jest.fn()); + const groups = getConfirmationItems(pages, getAllFields(pages), {type: 'PRIVATE', name: 'Ada', ein: '1'}, translateLocal, jest.fn(), jest.fn()); // Then only the identity page is summarized expect(groups.map((group) => group.id)).toEqual(['identity']); }); + + it('shows each list entry as its own row that opens the entry', () => { + // Given a page with a list of directors holding one entry + const directorsPages: DynamicFormPage[] = [ + { + key: 'directors', + content: [ + { + key: 'directors', + type: 'list', + required: true, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'lastName', type: 'text', required: true}, + ], + }, + ], + }, + ]; + const values = {directors: [{id: 'jane', firstName: 'Jane', lastName: 'Doe'}]}; + const onEditListItem = jest.fn(); + + // When the confirmation sections are built and the entry row is tapped + const row = getConfirmationItems(directorsPages, getAllFields(directorsPages), values, translateLocal, jest.fn(), onEditListItem).at(0)?.rows.at(0); + row?.onPress(); + + // Then the entry is an avatar row named by its leading text answers, and tapping it opens that entry rather than the page + expect(row).toMatchObject({kind: 'avatar', title: 'Jane Doe'}); + expect(onEditListItem).toHaveBeenCalledWith('directors', 'jane'); + }); + + it('shows an empty list as a row that opens its page', () => { + // Given a page with an optional list of directors that holds no entry + const directorsPages: DynamicFormPage[] = [ + { + key: 'directors', + content: [{key: 'directors', label: 'Directors', type: 'list', required: false, itemFields: [{key: 'firstName', type: 'text', required: true}]}], + }, + ]; + const onEditPage = jest.fn(); + const onEditListItem = jest.fn(); + + // When the confirmation sections are built and the list row is tapped + const row = getConfirmationItems(directorsPages, getAllFields(directorsPages), {directors: []}, translateLocal, onEditPage, onEditListItem).at(0)?.rows.at(0); + row?.onPress(); + + // Then the list still has a row, so the user can go back and add a director from the confirmation page + expect(row).toMatchObject({description: 'Directors', title: ''}); + expect(onEditPage).toHaveBeenCalledWith(0); + expect(onEditListItem).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts b/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts index 979dbd31801a..d2ea0e75f030 100644 --- a/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts +++ b/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts @@ -1,5 +1,6 @@ import getAddressInputKeys from '@components/DynamicForm/utils/getAddressInputKeys'; import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; +import {getListItemSensitiveKey} from '@components/DynamicForm/utils/listItems'; import Log from '@libs/Log'; import {getCountryZipRegexDetails} from '@libs/ValidationUtils'; @@ -369,4 +370,48 @@ describe('getDynamicFieldErrors', () => { expect(unansweredErrors).toEqual({hasOtherOwners: translateLocal('common.error.fieldRequired')}); expect(consentErrors).toEqual({acceptTerms: translateLocal('common.error.fieldRequired')}); }); + + it('checks a list for its size and for the first entry with a problem', () => { + // Given a list of at least two directors, whose entries need a first name and may hold a sensitive SSN + const directors: DynamicFormField = { + key: 'directors', + type: 'list', + required: true, + minItems: 2, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: true, sensitive: true}, + ], + }; + + // When it holds no entry, and when it holds one entry without a first name + const emptyErrors = getDynamicFieldErrors([directors], {directors: []}, translateLocal); + const shortErrors = getDynamicFieldErrors([directors], {directors: [{id: 'jane', firstName: ''}]}, translateLocal); + + // Then the empty list is required, and the short list shows both the size and the entry's first problem, its missing name + expect(emptyErrors).toEqual({directors: translateLocal('common.error.fieldRequired')}); + expect(shortErrors).toEqual({directors: `${translateLocal('dynamicForm.error.tooFewItems', {min: 2})}\n${translateLocal('common.error.fieldRequired')}`}); + }); + + it('asks again for a sensitive entry answer the memory no longer holds', () => { + // Given a director whose required SSN is kept in memory apart from the drafted entry + const directors: DynamicFormField = { + key: 'directors', + type: 'list', + required: true, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: true, sensitive: true}, + ], + }; + const draftValues = {directors: [{id: 'jane', firstName: 'Jane'}]}; + + // When the SSN is in memory, and when the memory was lost, as after the app restarts + const keptErrors = getDynamicFieldErrors([directors], {...draftValues, [getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}, translateLocal); + const lostErrors = getDynamicFieldErrors([directors], draftValues, translateLocal); + + // Then the list passes while the SSN is kept, and asks for it once it is lost, so a director is never submitted without one + expect(keptErrors).toEqual({}); + expect(lostErrors).toEqual({directors: translateLocal('common.error.fieldRequired')}); + }); }); diff --git a/tests/unit/components/DynamicForm/getSubmittedAnswersTest.ts b/tests/unit/components/DynamicForm/getSubmittedAnswersTest.ts index fe7bd009fd79..f8f25c655a7c 100644 --- a/tests/unit/components/DynamicForm/getSubmittedAnswersTest.ts +++ b/tests/unit/components/DynamicForm/getSubmittedAnswersTest.ts @@ -1,5 +1,6 @@ import getAddressInputKeys from '@components/DynamicForm/utils/getAddressInputKeys'; import getSubmittedAnswers, {getAnswerKeys} from '@components/DynamicForm/utils/getSubmittedAnswers'; +import {getListItemSensitiveKey} from '@components/DynamicForm/utils/listItems'; import type {DynamicFormField} from '@src/types/onyx'; @@ -57,4 +58,24 @@ describe('getSubmittedAnswers', () => { expect(keys).toContain('home'); expect(keys).toContain(cityKey); }); + + it('submits each list entry with its sensitive answers kept in memory', () => { + // Given a list of directors whose SSN is sensitive, so it is kept in memory under the entry instead of in the drafted entry + const directors: DynamicFormField = { + key: 'directors', + type: 'list', + required: true, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: true, sensitive: true}, + ], + }; + const values = {directors: [{id: 'jane', firstName: 'Jane'}], [getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}; + + // When the answers are collected + const answers = getSubmittedAnswers([directors], values); + + // Then the entry is sent whole, SSN included, without the memory key it was kept under + expect(answers).toEqual({directors: [{id: 'jane', firstName: 'Jane', ssn: '123456789'}]}); + }); }); diff --git a/tests/unit/components/DynamicForm/summarizeListItemTest.ts b/tests/unit/components/DynamicForm/summarizeListItemTest.ts new file mode 100644 index 000000000000..3bb3b79bc226 --- /dev/null +++ b/tests/unit/components/DynamicForm/summarizeListItemTest.ts @@ -0,0 +1,38 @@ +import summarizeListItem from '@components/DynamicForm/utils/summarizeListItem'; + +import type {DynamicFormField} from '@src/types/onyx'; + +import {translateLocal} from '../../../utils/TestHelper'; + +describe('summarizeListItem', () => { + it('names an entry by its leading text answers and describes it with up to two short ones', () => { + // Given an owner with a name, an SSN, a date of birth and three short answers + const itemFields: DynamicFormField[] = [ + {key: 'firstName', type: 'text', required: true}, + {key: 'lastName', type: 'text', required: true}, + {key: 'ssn', type: 'text', required: true, sensitive: true}, + {key: 'dateOfBirth', type: 'date', required: true}, + {key: 'ownership', type: 'percent', required: true}, + {key: 'role', type: 'select', required: true, values: [{key: 'CEO', label: 'Chief executive'}]}, + {key: 'title', type: 'text', required: false}, + ]; + const owner = {id: 'jane', firstName: 'Jane', lastName: 'Doe', ssn: '123456789', dateOfBirth: '1980-04-19', ownership: '40', role: 'CEO', title: 'Founder'}; + + // When the entry is summarized + const summary = summarizeListItem(owner, itemFields, translateLocal); + + // Then the name is the title, and the description holds the first two short answers, leaving out the SSN and the long date + expect(summary).toEqual({title: 'Jane Doe', description: '40%, Chief executive'}); + }); + + it('names an entry without text answers by its first answer', () => { + // Given an entry holding only a choice + const itemFields: DynamicFormField[] = [{key: 'role', type: 'select', required: true, values: [{key: 'CEO', label: 'Chief executive'}]}]; + + // When it is summarized + const summary = summarizeListItem({id: 'one', role: 'CEO'}, itemFields, translateLocal); + + // Then the choice names the row, so it is never left without a title + expect(summary).toEqual({title: 'Chief executive', description: ''}); + }); +}); diff --git a/tests/unit/components/DynamicForm/toDynamicFormValuesTest.ts b/tests/unit/components/DynamicForm/toDynamicFormValuesTest.ts index 373681ef6f2a..ea5f5453bfe9 100644 --- a/tests/unit/components/DynamicForm/toDynamicFormValuesTest.ts +++ b/tests/unit/components/DynamicForm/toDynamicFormValuesTest.ts @@ -8,6 +8,7 @@ describe('toDynamicFormValues', () => { accepted: true, countries: ['GB', 'US'], files: [{name: 'passport.pdf'}], + directors: [{id: 'jane', firstName: 'Jane'}], errors: {name: 'Required'}, errorList: [{message: 'Required'}], mixedList: ['GB', 1], @@ -18,7 +19,7 @@ describe('toDynamicFormValues', () => { const values = toDynamicFormValues(draft); // Then only the answers remain - expect(values).toEqual({name: 'Jane', accepted: true, countries: ['GB', 'US'], files: [{name: 'passport.pdf'}]}); + expect(values).toEqual({name: 'Jane', accepted: true, countries: ['GB', 'US'], files: [{name: 'passport.pdf'}], directors: [{id: 'jane', firstName: 'Jane'}]}); }); it('returns no answers for a missing draft', () => { From 56f5dc67c38ff979bbf11509cf3d30aaf075da4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Poche=C4=87?= Date: Tue, 13 Oct 2026 18:18:50 +0200 Subject: [PATCH 575/781] Submit the last DynamicForm page only while online --- .../components/DynamicFormFieldsPage.tsx | 3 +- .../DynamicForm/DynamicFormFieldsPageTest.tsx | 54 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx diff --git a/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx b/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx index 4b0cd5549e65..a56e039b1b84 100644 --- a/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx +++ b/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx @@ -52,7 +52,8 @@ function DynamicFormFieldsPage({ style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={styles.mb0} keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} - enabledWhenOffline + // An offline request is saved to disk, so the last page submits only online, as the confirmation page does + enabledWhenOffline={!isLastPage} > {({inputValues}) => { const currentValues = withInputValues(inputValues); diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx new file mode 100644 index 000000000000..56b54c189679 --- /dev/null +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx @@ -0,0 +1,54 @@ +import {render} from '@testing-library/react-native'; + +import DynamicFormFieldsPage from '@components/DynamicForm/components/DynamicFormFieldsPage'; +import FormProvider from '@components/Form/FormProvider'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DynamicFormPage} from '@src/types/onyx'; + +import React from 'react'; + +jest.mock('@components/Form/FormProvider', () => jest.fn(() => null)); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); + +const page: DynamicFormPage = {key: 'identity', title: 'Identity', content: [{key: 'ssn', type: 'text', required: true, sensitive: true}]}; + +function renderPage(isLastPage: boolean) { + render( + , + ); + return jest.mocked(FormProvider).mock.lastCall?.[0]; +} + +describe('DynamicFormFieldsPage', () => { + it('lets the user move on to the next page while offline', () => { + // Given a page that a later page or a confirmation page follows + // When it renders + const formProps = renderPage(false); + + // Then its button works offline, since moving between pages sends nothing + expect(formProps?.enabledWhenOffline).toBe(true); + }); + + it('submits the last page only while online', () => { + // Given the last page of a flow without a confirmation page, so its button submits the form + // When it renders + const formProps = renderPage(true); + + // Then its button is disabled offline, since an offline request is saved to disk with the sensitive answers it carries + expect(formProps?.enabledWhenOffline).toBe(false); + }); +}); From d2571faeb53917b4019bfa9e37de764a5866e634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Poche=C4=87?= Date: Tue, 13 Oct 2026 18:18:52 +0200 Subject: [PATCH 576/781] Refill every sensitive DynamicForm input from memory --- .../DynamicForm/renderDynamicField.tsx | 3 +- .../DynamicForm/renderers/addressRenderer.tsx | 16 ++++- .../DynamicForm/renderers/choiceRenderers.tsx | 9 ++- .../DynamicForm/renderers/textRenderers.tsx | 18 ++--- src/components/DynamicForm/renderers/types.ts | 34 +++++++-- .../utils/getSensitiveDefaultValue.ts | 52 ++++++++++++++ .../DynamicForm/DynamicFormFieldsTest.tsx | 72 ++++++++++++++++++- 7 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 src/components/DynamicForm/utils/getSensitiveDefaultValue.ts diff --git a/src/components/DynamicForm/renderDynamicField.tsx b/src/components/DynamicForm/renderDynamicField.tsx index 63d45bd53110..e4f38a8da5b4 100644 --- a/src/components/DynamicForm/renderDynamicField.tsx +++ b/src/components/DynamicForm/renderDynamicField.tsx @@ -7,6 +7,7 @@ import renderAddress from './renderers/addressRenderer'; import CHOICE_RENDERERS from './renderers/choiceRenderers'; import renderFile from './renderers/fileRenderer'; import TEXT_RENDERERS from './renderers/textRenderers'; +import getSensitiveDefaultValue from './utils/getSensitiveDefaultValue'; const RENDERERS: DynamicFieldRendererMap = { ...TEXT_RENDERERS, @@ -18,7 +19,7 @@ const RENDERERS: DynamicFieldRendererMap = { /** The input for one field, and how the renderer lays it out */ function renderDynamicField(field: DynamicFormFieldOfType, context: DynamicFieldContext, inputProps: DynamicFieldInputProps): DynamicFieldInput { const render: DynamicFieldRenderer = RENDERERS[field.type]; - return render(field, context, inputProps); + return render(field, context, {...inputProps, defaultValue: getSensitiveDefaultValue(field, context.values)}); } export default renderDynamicField; diff --git a/src/components/DynamicForm/renderers/addressRenderer.tsx b/src/components/DynamicForm/renderers/addressRenderer.tsx index e4bfe3ffb426..2b178ba0ac90 100644 --- a/src/components/DynamicForm/renderers/addressRenderer.tsx +++ b/src/components/DynamicForm/renderers/addressRenderer.tsx @@ -1,4 +1,5 @@ import getAddressInputKeys, {getAddressCountry, isStateAsked} from '@components/DynamicForm/utils/getAddressInputKeys'; +import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; import AddressFormFields from '@pages/ReimbursementAccount/AddressFormFields'; @@ -7,9 +8,14 @@ import React from 'react'; import type {DynamicFieldRenderer} from './types'; /** An address drawn as one input per part, each under its own draft key */ -const renderAddress: DynamicFieldRenderer<'address'> = (field, {values}, {shouldSaveDraft, forwardedFSClass, onValueChange}) => { +const renderAddress: DynamicFieldRenderer<'address'> = (field, {values}, {shouldSaveDraft, forwardedFSClass, onValueChange, defaultValue}) => { const addressKeys = getAddressInputKeys(field.key); const country = getAddressCountry(field.key, values); + // The street arrives as the field's answer. The other parts of a sensitive address are not drafted either, so they start from memory too. + const getSensitivePart = (key: string) => { + const part = values[key]; + return isSensitiveField(field) && typeof part === 'string' ? part : undefined; + }; return { isMenuRow: false, labelAbove: 'heading', @@ -17,7 +23,13 @@ const renderAddress: DynamicFieldRenderer<'address'> = (field, {values}, {should { } /** A lone choice field is the page itself: its options are listed inline, and the page title asks the question */ -function renderInlineChoice(label: string, choices: Choice[], inputProps: DynamicFieldInputProps, {canSelectMultiple, isSearchable}: InlineChoiceOptions): DynamicFieldInput { +function renderInlineChoice( + label: string, + choices: Choice[], + inputProps: DynamicFieldInputProps, + {canSelectMultiple, isSearchable}: InlineChoiceOptions, +): DynamicFieldInput { const listProps = {items: choices, isSearchable, searchInputLabel: label}; return { isMenuRow: true, @@ -46,7 +51,7 @@ function renderInlineChoice(label: string, choices: Choice[], inputProps: Dynami }; } -function renderTabs(label: string, choices: Choice[], inputProps: DynamicFieldInputProps, isLoneField: boolean): DynamicFieldInput { +function renderTabs(label: string, choices: Choice[], inputProps: DynamicFieldInputProps, isLoneField: boolean): DynamicFieldInput { return { isMenuRow: false, labelAbove: isLoneField ? undefined : 'prompt', diff --git a/src/components/DynamicForm/renderers/textRenderers.tsx b/src/components/DynamicForm/renderers/textRenderers.tsx index 5033688a9dd3..9e7167883672 100644 --- a/src/components/DynamicForm/renderers/textRenderers.tsx +++ b/src/components/DynamicForm/renderers/textRenderers.tsx @@ -1,7 +1,6 @@ import AmountForm from '@components/AmountForm'; import CurrencyPicker from '@components/CurrencyPicker'; import DatePicker from '@components/DatePicker'; -import type {DynamicFormValues} from '@components/DynamicForm/types'; import getLocalizedText, {getFieldLabel} from '@components/DynamicForm/utils/getLocalizedText'; import InputWrapper from '@components/Form/InputWrapper'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; @@ -41,16 +40,10 @@ function getTextHint(field: DynamicFormTextField | DynamicFormNumberField, trans return translate('dynamicForm.exampleHint', {example: field.example}); } -/** Sensitive answers are not drafted, so a page the user comes back to is refilled from the answers kept in memory */ -function getSensitiveDefaultValue(field: DynamicFormTextField | DynamicFormNumberField, values: DynamicFormValues): string | undefined { - const value = values[field.key]; - return field.sensitive && typeof value === 'string' ? value : undefined; -} - function renderTextInput( field: DynamicFormTextField | DynamicFormNumberField, - {values, translate}: DynamicFieldContext, - inputProps: DynamicFieldInputProps, + {translate}: DynamicFieldContext, + inputProps: DynamicFieldInputProps, inputMode: InputModeOptions | undefined, isMultiline = false, ): DynamicFieldInput { @@ -61,7 +54,6 @@ function renderTextInput( { const chosenCurrency = field.currencyKey ? values[field.currencyKey] : undefined; + const amountCurrency = typeof chosenCurrency === 'string' && chosenCurrency !== '' ? chosenCurrency : currency; // AmountForm takes no FullStory class, so the mask goes on a View around it const amountInput = ( @@ -98,7 +91,7 @@ const TEXT_RENDERERS: Pick @@ -117,7 +110,8 @@ const TEXT_RENDERERS: Pick diff --git a/src/components/DynamicForm/renderers/types.ts b/src/components/DynamicForm/renderers/types.ts index 89825a0825b7..59d198f9d43b 100644 --- a/src/components/DynamicForm/renderers/types.ts +++ b/src/components/DynamicForm/renderers/types.ts @@ -1,12 +1,13 @@ import type {DynamicFormFieldsProps} from '@components/DynamicForm/components/DynamicFormFields'; import type {DynamicFormFieldOfType} from '@components/DynamicForm/types'; -import type {InputComponentBaseProps} from '@components/Form/types'; +import type {FormValue, InputComponentBaseProps} from '@components/Form/types'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {ThemeStyles} from '@src/styles'; import type {DynamicFormFieldType} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; import type {ReactElement} from 'react'; @@ -19,11 +20,32 @@ type DynamicFieldContext = Pick & +type DynamicFieldInputProps = Required & Required> & Pick & { onBlur?: () => void; + + /** A sensitive answer is not drafted, so the input starts from the answer the flow keeps in memory */ + defaultValue?: TAnswer; }; type DynamicFieldInput = { @@ -40,9 +62,13 @@ type DynamicFieldInput = { showsDescription?: boolean; }; -type DynamicFieldRenderer = (field: DynamicFormFieldOfType, context: DynamicFieldContext, inputProps: DynamicFieldInputProps) => DynamicFieldInput; +type DynamicFieldRenderer = ( + field: DynamicFormFieldOfType, + context: DynamicFieldContext, + inputProps: DynamicFieldInputProps, +) => DynamicFieldInput; /** One renderer per field type, so typecheck fails when a type has none */ type DynamicFieldRendererMap = {[TType in DynamicFormFieldType]: DynamicFieldRenderer}; -export type {DynamicFieldContext, DynamicFieldInput, DynamicFieldInputProps, DynamicFieldRenderer, DynamicFieldRendererMap}; +export type {DynamicFieldAnswerMap, DynamicFieldContext, DynamicFieldInput, DynamicFieldInputProps, DynamicFieldRenderer, DynamicFieldRendererMap}; diff --git a/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts new file mode 100644 index 000000000000..6d2e998e0951 --- /dev/null +++ b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts @@ -0,0 +1,52 @@ +import type {DynamicFieldAnswerMap} from '@components/DynamicForm/renderers/types'; +import type {DynamicFormFieldOfType, DynamicFormValues} from '@components/DynamicForm/types'; +import type {FormValue} from '@components/Form/types'; + +import type {DynamicFormFieldType} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import isSensitiveField from './isSensitiveField'; + +type AnswerGuards = {[TType in DynamicFormFieldType]: (answer: FormValue) => answer is DynamicFieldAnswerMap[TType]}; + +function isString(answer: FormValue): answer is string { + return typeof answer === 'string'; +} + +function isStringList(answer: FormValue): answer is string[] { + return Array.isArray(answer) && answer.every((item) => typeof item === 'string'); +} + +function isBoolean(answer: FormValue): answer is boolean { + return typeof answer === 'boolean'; +} + +function isFileList(answer: FormValue): answer is FileObject[] { + return Array.isArray(answer) && answer.every((item) => typeof item === 'object'); +} + +const ANSWER_GUARDS: AnswerGuards = { + text: isString, + number: isString, + select: isString, + radio: isString, + multiselect: isStringList, + date: isString, + address: isString, + file: isFileList, + amount: isString, + country: isString, + countryMultiselect: isStringList, + boolean: isBoolean, + currency: isString, + percent: isString, +}; + +/** Sensitive answers are not drafted, so a page the user comes back to is refilled from the answers kept in memory */ +function getSensitiveDefaultValue(field: DynamicFormFieldOfType, values: DynamicFormValues): DynamicFieldAnswerMap[TType] | undefined { + const answer = values[field.key]; + const isAnswer: AnswerGuards[TType] = ANSWER_GUARDS[field.type]; + return isSensitiveField(field) && answer !== undefined && isAnswer(answer) ? answer : undefined; +} + +export default getSensitiveDefaultValue; diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx index eb27f45c4136..d031f0d203a4 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx @@ -14,6 +14,7 @@ import YesNoAdapter from '@components/DynamicForm/adapters/YesNoAdapter'; import DynamicFormFields from '@components/DynamicForm/components/DynamicFormFields'; import type {DynamicFormValues} from '@components/DynamicForm/types'; import getAddressInputKeys from '@components/DynamicForm/utils/getAddressInputKeys'; +import type {FormValue} from '@components/Form/types'; import PercentageForm from '@components/PercentageForm'; import PushRowWithModal from '@components/PushRowWithModal'; import RadioButtons from '@components/RadioButtons'; @@ -44,7 +45,7 @@ type CapturedInputProps = { maxFileSize?: number; accessibilityLabel?: string; currency?: string; - defaultValue?: string; + defaultValue?: unknown; onValueChange?: (value: string, key: string) => void; onBlur?: () => void; }; @@ -111,6 +112,23 @@ const EXPECTED_INPUT_BY_TYPE: Record> const FIELD_TYPES = Object.keys(EXPECTED_INPUT_BY_TYPE).filter((type): type is DynamicFormFieldType => Object.hasOwn(EXPECTED_INPUT_BY_TYPE, type)); +const SENSITIVE_ANSWER_BY_TYPE: Record = { + text: 'Jane', + number: '123456789', + select: 'SAVINGS', + radio: 'SAVINGS', + multiselect: ['RETAIL'], + date: '1990-05-01', + address: '1 Main St', + file: [{name: 'passport.pdf', uri: 'file://passport.pdf'}], + amount: '1000', + country: 'PL', + countryMultiselect: ['PL', 'DE'], + boolean: true, + currency: 'EUR', + percent: '25', +}; + describe('DynamicFormFields', () => { it.each(FIELD_TYPES)('renders a %s field with its input', (type) => { // Given a field of one type with nothing but the required properties, next to another question so it is drawn as a row @@ -142,6 +160,58 @@ describe('DynamicFormFields', () => { expect(rendered.get('nationality')?.shouldSaveDraft).toBe(true); }); + it.each(FIELD_TYPES)('refills a sensitive %s field from the answer kept in memory', (type) => { + // Given a sensitive field of one type, whose answer is kept in memory instead of in the draft + const field: DynamicFormField = {key: 'answer', type, required: true, sensitive: true}; + const answer = SENSITIVE_ANSWER_BY_TYPE[type]; + + // When the user comes back to its page + const rendered = renderFields([field, otherQuestion], {answer}); + + // Then the input starts from that answer, so the user does not have to give it again + expect(rendered.get('answer')?.defaultValue).toEqual(answer); + }); + + it('leaves a regular field to its draft when the user comes back to its page', () => { + // Given a regular field with an answer, which FormProvider refills from the draft + const nationality: DynamicFormField = {key: 'nationality', type: 'country', required: true}; + + // When its page renders + const rendered = renderFields([nationality, otherQuestion], {nationality: 'PL'}); + + // Then the input gets no default of its own, so the draft stays the one source of its answer + expect(rendered.get('nationality')?.defaultValue).toBeUndefined(); + }); + + it('refills every part of a sensitive address from memory', () => { + // Given a sensitive address whose parts are kept in memory + const homeAddress: DynamicFormField = {key: 'homeAddress', type: 'address', required: true, sensitive: true}; + const keys = getAddressInputKeys('homeAddress'); + const values = {homeAddress: '1 Main St', [keys.city]: 'Austin', [keys.state]: 'TX', [keys.zipCode]: '78701', [keys.country]: CONST.COUNTRY.US}; + + // When the user comes back to its page + const rendered = renderFields([homeAddress], values); + + // Then every part starts from its answer, so none of them is asked again + expect(rendered.get(keys.street)?.defaultValue).toBe('1 Main St'); + expect(rendered.get(keys.city)?.defaultValue).toBe('Austin'); + expect(rendered.get(keys.state)?.defaultValue).toBe('TX'); + expect(rendered.get(keys.zipCode)?.defaultValue).toBe('78701'); + expect(rendered.get(keys.country)?.defaultValue).toBe(CONST.COUNTRY.US); + }); + + it('refills the currency of a sensitive amount from memory', () => { + // Given a sensitive amount whose currency the user picked, both kept in memory + const expectedVolume: DynamicFormField = {key: 'expectedVolume', type: 'amount', required: true, sensitive: true, currencyKey: 'expectedVolumeCurrency'}; + + // When the user comes back to its page + const rendered = renderFields([expectedVolume], {expectedVolume: '1000', expectedVolumeCurrency: CONST.CURRENCY.EUR}, {currency: CONST.CURRENCY.GBP}); + + // Then the picker starts from the picked currency rather than the screen currency + expect(rendered.get('expectedVolumeCurrency')?.defaultValue).toBe(CONST.CURRENCY.EUR); + expect(rendered.get('expectedVolume')?.defaultValue).toBe('1000'); + }); + it('offers the options picked by the answer a select depends on', () => { // Given an account type whose options depend on the recipient type const accountType: DynamicFormField = { From f2e717309b64320e1ae8f7681202a7e786d14ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Poche=C4=87?= Date: Tue, 13 Oct 2026 18:18:52 +0200 Subject: [PATCH 577/781] Add When steps to DynamicForm tests --- .../DynamicForm/DynamicFormFlowTest.tsx | 3 +- .../DynamicForm/getConfirmationItemsTest.ts | 5 +- .../components/DynamicForm/paginateTest.ts | 52 +++++++++++++------ 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx index 5f44cc6016dc..ad705291438a 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx @@ -174,7 +174,8 @@ describe('DynamicFormFlow', () => { }); it('asks for a confirmation page unless the screen turns it off', async () => { - // Given the same schema rendered with the default and with the confirmation page turned off + // Given the same schema, once with the default and once with the confirmation page turned off + // When each flow renders const defaultFlow = await renderFlow({hasConfirmation: 'default'}); const withoutConfirmation = await renderFlow({hasConfirmation: false}); diff --git a/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts b/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts index 4a4585a2f71a..28cda94ecf5c 100644 --- a/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts +++ b/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts @@ -49,7 +49,10 @@ describe('getConfirmationItems', () => { it('has no group for a page that is not shown', () => { // Given a private recipient, so the business page is skipped - const groups = getConfirmationItems(pages, getAllFields(pages), {type: 'PRIVATE', name: 'Ada', ein: '1'}, translateLocal, jest.fn()); + const values = {type: 'PRIVATE', name: 'Ada', ein: '1'}; + + // When the confirmation page lists the answers + const groups = getConfirmationItems(pages, getAllFields(pages), values, translateLocal, jest.fn()); // Then only the identity page is summarized expect(groups.map((group) => group.id)).toEqual(['identity']); diff --git a/tests/unit/components/DynamicForm/paginateTest.ts b/tests/unit/components/DynamicForm/paginateTest.ts index 2a12e4f0b5ba..c690adc3eae6 100644 --- a/tests/unit/components/DynamicForm/paginateTest.ts +++ b/tests/unit/components/DynamicForm/paginateTest.ts @@ -1,7 +1,7 @@ import getPageTitle, {isPageShown} from '@components/DynamicForm/utils/getPageTitle'; import paginate, {getAllFields, getPages} from '@components/DynamicForm/utils/paginate'; -import type {DynamicFormField, DynamicFormPage, DynamicFormTextField} from '@src/types/onyx'; +import type {DynamicFormField, DynamicFormPage, DynamicFormSchemaField, DynamicFormTextField} from '@src/types/onyx'; import {translateLocal} from '../../../utils/TestHelper'; @@ -49,7 +49,10 @@ describe('paginate', () => { it('starts a new page after four questions in a row', () => { // Given five short questions - const pages = paginate([text('a'), text('b'), text('c'), text('d'), text('e')]); + const fields = [text('a'), text('b'), text('c'), text('d'), text('e')]; + + // When they are split into pages + const pages = paginate(fields); // Then the fifth does not fit the shared page expect(keysOf(pages)).toEqual([['a', 'b', 'c', 'd'], ['e']]); @@ -92,7 +95,10 @@ describe('paginate', () => { describe('getPages', () => { it('names pages built from fields page-N', () => { // Given a flat schema - const pages = getPages({fields: [text('a'), {key: 'address', type: 'address', required: true}]}); + const fields: DynamicFormSchemaField[] = [text('a'), {key: 'address', type: 'address', required: true}]; + + // When its pages are built + const pages = getPages({fields}); // Then the pages get stable keys expect(pages.map((page) => page.key)).toEqual(['page-1', 'page-2']); @@ -101,14 +107,15 @@ describe('getPages', () => { it('turns keys into URL-safe slugs, reserves confirm and makes keys unique', () => { // Given pages whose keys are not URL-safe, collide, or take the confirmation route const content = [text('a')]; - const pages = getPages({ - pages: [ - {key: 'Bank Info', content}, - {key: 'bank-info', content}, - {key: 'confirm', content}, - {key: '###', content}, - ], - }); + const schemaPages: DynamicFormPage[] = [ + {key: 'Bank Info', content}, + {key: 'bank-info', content}, + {key: 'confirm', content}, + {key: '###', content}, + ]; + + // When the schema's pages are read + const pages = getPages({pages: schemaPages}); // Then each is a unique route segment expect(pages.map((page) => page.key)).toEqual(['bank-info', 'bank-info-2', 'confirm-2', 'page-4']); @@ -120,8 +127,11 @@ describe('getPageTitle', () => { // Given a page with a titleKey const page = {key: 'p', titleKey: 'common.next', content: [text('a')]} as DynamicFormPage; + // When its title is read + const title = getPageTitle(page, {}, translateLocal); + // Then the translation is used - expect(getPageTitle(page, {}, translateLocal)).toBe(translateLocal('common.next')); + expect(title).toBe(translateLocal('common.next')); }); it('falls back to the label of the lone question, ignoring a field it reveals', () => { @@ -139,8 +149,11 @@ describe('getPageTitle', () => { // Given an untitled page whose first field is hidden const page: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Alpha', showWhen: {key: 'z', equals: ['1']}}), text('b', {label: 'Beta'}), text('c', {label: 'Gamma'})]}; + // When its title is read + const title = getPageTitle(page, {}, translateLocal); + // Then the title is the first field the user sees - expect(getPageTitle(page, {}, translateLocal)).toBe('Beta'); + expect(title).toBe('Beta'); }); }); @@ -151,16 +164,23 @@ describe('isPageShown', () => { const page: DynamicFormPage = {key: 'biz', showWhen: {key: 'type', equals: ['BUSINESS']}, content: [text('ein')]}; const allFields = getAllFields([{key: 'first', content: [type]}, page]); + // When the user answers private, and then business + const isShownForPrivate = isPageShown(page, {type: 'PRIVATE'}, allFields); + const isShownForBusiness = isPageShown(page, {type: 'BUSINESS'}, allFields); + // Then it follows the answer - expect(isPageShown(page, {type: 'PRIVATE'}, allFields)).toBe(false); - expect(isPageShown(page, {type: 'BUSINESS'}, allFields)).toBe(true); + expect(isShownForPrivate).toBe(false); + expect(isShownForBusiness).toBe(true); }); it('hides a page with no visible field', () => { // Given a page whose only field is hidden const page: DynamicFormPage = {key: 'p', content: [text('a', {showWhen: {key: 'z', equals: ['1']}})]}; + // When the flow checks whether to show it + const isShown = isPageShown(page, {}, getAllFields([page])); + // Then the page is skipped - expect(isPageShown(page, {}, getAllFields([page]))).toBe(false); + expect(isShown).toBe(false); }); }); From d915f48f51c8ff7e92fbefde02f3ab7267b00138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Poche=C4=87?= Date: Tue, 13 Oct 2026 18:18:53 +0200 Subject: [PATCH 578/781] Split a getConfirmationItems comment into two sentences --- src/components/DynamicForm/utils/getConfirmationItems.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DynamicForm/utils/getConfirmationItems.ts b/src/components/DynamicForm/utils/getConfirmationItems.ts index a1d2f74782c0..f082f03d817f 100644 --- a/src/components/DynamicForm/utils/getConfirmationItems.ts +++ b/src/components/DynamicForm/utils/getConfirmationItems.ts @@ -15,7 +15,7 @@ import isSensitiveField from './isSensitiveField'; import isSupportedField from './isSupportedField'; import maskSensitiveValue from './maskSensitiveValue'; -/** One section per shown page holding its visible answers. Tapping a row opens the page to edit; readonly rows open nothing. */ +/** One section per shown page holding its visible answers. Tapping a row opens the page to edit. Readonly rows open nothing. */ function getConfirmationItems( pages: DynamicFormPage[], allFields: DynamicFormField[], From f10366a8f5670798ec7d070b0e03932283e3bbb9 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Tue, 13 Oct 2026 19:52:19 +0000 Subject: [PATCH 579/781] Guard search snapshot data reads that crash while a search is loading A new search's snapshot has only `search` until its results arrive, so `currentSearchResults?.data[...]` throws. Guard the remaining unguarded reads and cover the bulk actions case with a test. Co-authored-by: Kevin Brian Bader --- .../ReportActionItem/MoneyRequestView.tsx | 2 +- src/hooks/useMergeTransactions.ts | 2 +- .../Search/SearchReportsMergeReports.tsx | 2 +- tests/unit/hooks/useSearchBulkActionsTest.ts | 31 ++++++++++++++++++- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index fa9f09ab9d60..a9063417711a 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -229,7 +229,7 @@ function MoneyRequestView({ // When this component is used when merging from the search page, we might not have the parent report stored in the main collection const [parentReportFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); - const parentReport = parentReportFromOnyx ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; + const parentReport = parentReportFromOnyx ?? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); diff --git a/src/hooks/useMergeTransactions.ts b/src/hooks/useMergeTransactions.ts index 51d4230c0875..50c1b2d00af5 100644 --- a/src/hooks/useMergeTransactions.ts +++ b/src/hooks/useMergeTransactions.ts @@ -56,7 +56,7 @@ function getTransaction( return transaction; } - return currentSearchResults?.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? onyxTransaction; + return currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? onyxTransaction; } function useMergeTransactions({mergeTransaction}: UseMergeTransactionsProps): UseMergeTransactionsReturn { diff --git a/src/pages/Search/SearchReportsMergeReports.tsx b/src/pages/Search/SearchReportsMergeReports.tsx index 1f7fff4c4167..33cb628dbf47 100644 --- a/src/pages/Search/SearchReportsMergeReports.tsx +++ b/src/pages/Search/SearchReportsMergeReports.tsx @@ -223,7 +223,7 @@ function SearchMergeReports() { isLoadingOnyxData || selectedReportIDs?.some((reportID) => { const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const; - return !allReports?.[reportKey] && !!currentSearchResults?.data[reportKey]; + return !allReports?.[reportKey] && !!currentSearchResults?.data?.[reportKey]; }); if (!isReportMergeBetaEnabled) { diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 369ca3dfc0d2..441409888e8e 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -168,7 +168,7 @@ let mockSelectedTransactions: SelectedTransactions = {}; let mockExcludedTransactions: SelectedTransactions = {}; let mockSelectedReports: SelectedReports[] = []; let mockAreAllMatchingItemsSelected = false; -let mockCurrentSearchResults: {search: {type: string}; data: Record} | undefined; +let mockCurrentSearchResults: {search: {type: string}; data?: Record} | undefined; jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ @@ -515,6 +515,35 @@ describe('useSearchBulkActions - CSV export flow', () => { }); }); + it('does not crash when selected reports remain while the snapshot has no data', async () => { + // Given a report is still selected while a new search's snapshot only has `search` (its optimistic loading state), + // because the selection is cleared from an effect that runs after this render + mockSelectedTransactions = {tx1: makeSelectedTransaction({reportID: 'report1'})}; + mockSelectedReports = [ + { + reportID: 'report1', + policyID: 'policy1', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + canPay: false, + canApprove: false, + canSubmit: false, + canChangeApprover: false, + total: 100, + chatReportID: undefined, + }, + ]; + mockCurrentSearchResults = {search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}}; + + // When the bulk actions are created for an expense-report search + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then the hook renders its options instead of throwing, and Merge stays hidden until the results load + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + expect(result.current.headerButtonsOptions.some((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.MERGE_REPORTS)).toBe(false); + }); + it('handleBasicExport with manual selection does not track any export', async () => { mockAreAllMatchingItemsSelected = false; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; From 05211672507f72445946f5c4090ba7c2de98f54b Mon Sep 17 00:00:00 2001 From: X Developer Date: Tue, 13 Oct 2026 22:08:09 +0200 Subject: [PATCH 580/781] Refactor copyable touch tracking into shared row press hook --- src/components/MenuItem/MenuItem.tsx | 23 ++++++++++--------- .../MenuItem/layout/MenuItemRoot.tsx | 23 ++++++++++--------- .../useCopyableTextRowPress/index.native.ts | 3 +++ src/hooks/useCopyableTextRowPress/index.ts | 22 ++++++++++++++++-- .../components/MenuItem/MenuItemCopyTest.tsx | 20 ++++++++++------ .../hooks/useCopyableTextRowPress.test.ts | 21 ++++++++++++++++- 6 files changed, 80 insertions(+), 32 deletions(-) diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 100e4a1bc15b..851e177f5b4f 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -65,7 +65,7 @@ import type {GestureResponderEvent, ImageStyle, Role, StyleProp, TextStyle, View import type {AnimatedStyle} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; -import React, {useMemo, useRef, useState} from 'react'; +import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import useRemoveNonInteractiveClickHandler from './hooks/useRemoveNonInteractiveClickHandler'; @@ -591,10 +591,15 @@ function MenuItem({ const {singleExecution, waitForNavigate} = useMenuItemGroupActions() ?? {}; const popoverAnchor = useRef>(null); const pressableRef = useRef>(null); - const didTouchStartOnCopyableTextRef = useRef(false); - const [didTouchStartOnCopyableText, setDidTouchStartOnCopyableText] = useState(false); - const {isPressStartOnCopyableText, markMouseDownOnCopyableText, markTouchStartOnCopyableText, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress} = - useCopyableTextRowPress(); + const { + didTouchStartOnCopyableText, + isPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldPreserveNativeTextSelection, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + } = useCopyableTextRowPress({shouldTrackTouchStart: true}); useRemoveNonInteractiveClickHandler(pressableRef, interactive); const deviceHasHoverSupport = hasHoverSupport(); const isCompactMenu = useIsCompactMenu(); @@ -803,7 +808,7 @@ function MenuItem({ const secondaryInteractionHandler = copyable && !deviceHasHoverSupport ? secondaryInteraction : onSecondaryInteraction; const handlePressIn = () => { - if (shouldEnableTextSelection && didTouchStartOnCopyableTextRef.current) { + if (shouldEnableTextSelection && shouldPreserveNativeTextSelection()) { return; } if (shouldBlockSelection && shouldUseNarrowLayout && canUseTouchScreen()) { @@ -847,14 +852,10 @@ function MenuItem({ { - didTouchStartOnCopyableTextRef.current = false; - setDidTouchStartOnCopyableText(false); markMouseDownOnCopyableText(event?.target, isTitleSelectable); }} onTouchStart={(event) => { - const isCopyableTarget = markTouchStartOnCopyableText(event, shouldEnableTextSelection && isPressStartOnCopyableText(event)); - didTouchStartOnCopyableTextRef.current = isCopyableTarget; - setDidTouchStartOnCopyableText(isCopyableTarget); + markTouchStartOnCopyableText(event, shouldEnableTextSelection && isPressStartOnCopyableText(event)); }} shouldAllowTextSelection={isTitleSelectable} preventDefaultContextMenu={(event) => deviceHasHoverSupport || !isTitleSelectable || !isPressStartOnCopyableText(event)} diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index 0d4aceb606a3..4ac44215d566 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -25,7 +25,7 @@ import type WithTestID from '@src/types/utils/TestID'; import type {ComponentRef, PropsWithChildren} from 'react'; import type {AccessibilityState, GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; -import React, {useRef, useState} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; type MenuItemRootProps = PropsWithChildren & @@ -60,14 +60,19 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const pressableRef = useRef>(null); - const didTouchStartOnCopyableTextRef = useRef(false); - const [didTouchStartOnCopyableText, setDidTouchStartOnCopyableText] = useState(false); const isCompactPopover = useIsCompactPopover(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isInteractive = !!onPress; const shouldEnableTextSelection = shouldAllowTextSelection && getPlatform() === CONST.PLATFORM.WEB; - const {isPressStartOnCopyableText, markMouseDownOnCopyableText, markTouchStartOnCopyableText, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress} = - useCopyableTextRowPress(); + const { + didTouchStartOnCopyableText, + isPressStartOnCopyableText, + markMouseDownOnCopyableText, + markTouchStartOnCopyableText, + shouldPreserveNativeTextSelection, + shouldSuppressCopyableTextRowLongPress, + shouldSuppressCopyableTextRowPress, + } = useCopyableTextRowPress({shouldTrackTouchStart: true}); const {accessibilityLabel: rowAccessibilityLabel, accessibilityHint, registries: accessibilityRegistries} = useMenuItemAccessibility(accessibilityLabel); const {handler: registeredSecondaryInteraction, register: registerSecondaryInteraction} = useMenuItemSecondaryInteractionRegistry(); @@ -106,7 +111,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI const handlePressIn = () => { // RN Web responder events omit client coordinates, so reuse the original touch hit-test. - if (shouldEnableTextSelection && didTouchStartOnCopyableTextRef.current) { + if (shouldEnableTextSelection && shouldPreserveNativeTextSelection()) { return; } @@ -122,14 +127,10 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI { - didTouchStartOnCopyableTextRef.current = false; - setDidTouchStartOnCopyableText(false); markMouseDownOnCopyableText(event?.target, shouldEnableTextSelection); }} onTouchStart={(event) => { - const isCopyableTarget = markTouchStartOnCopyableText(event, shouldEnableTextSelection && isPressStartOnCopyableText(event)); - didTouchStartOnCopyableTextRef.current = isCopyableTarget; - setDidTouchStartOnCopyableText(isCopyableTarget); + markTouchStartOnCopyableText(event, shouldEnableTextSelection && isPressStartOnCopyableText(event)); }} shouldAllowTextSelection={shouldEnableTextSelection} preventDefaultContextMenu={(event) => !shouldEnableTextSelection || !isPressStartOnCopyableText(event)} diff --git a/src/hooks/useCopyableTextRowPress/index.native.ts b/src/hooks/useCopyableTextRowPress/index.native.ts index 572a1854a6b4..38833ef42b05 100644 --- a/src/hooks/useCopyableTextRowPress/index.native.ts +++ b/src/hooks/useCopyableTextRowPress/index.native.ts @@ -12,16 +12,19 @@ function useCopyableTextRowPress() { // Native does not expose browser selection APIs, so copyable text never suppresses row interactions here. const markMouseDownOnCopyableText: (target: EventTarget | null | undefined, shouldCheck?: boolean, options?: MarkCopyableTextMouseDownOptions) => boolean = () => false; const markTouchStartOnCopyableText: (event: unknown, shouldCheck?: boolean) => boolean = () => false; + const shouldPreserveNativeTextSelection = () => false; const shouldSuppressCopyableTextRowPress: (shouldCheck?: boolean) => boolean = () => false; const shouldSuppressCopyableTextRowLongPress: (shouldCheck?: boolean) => boolean = () => false; const shouldSuppressCopyableTextRowFocus = () => false; const handleCopyableTextRowPress: (onPress: () => void, options?: HandleCopyableTextRowPressOptions) => void = (onPress) => onPress(); return { + didTouchStartOnCopyableText: false, handleCopyableTextRowPress, isPressStartOnCopyableText, markMouseDownOnCopyableText, markTouchStartOnCopyableText, + shouldPreserveNativeTextSelection, shouldSuppressCopyableTextRowFocus, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress, diff --git a/src/hooks/useCopyableTextRowPress/index.ts b/src/hooks/useCopyableTextRowPress/index.ts index 9bcb0c37d762..6e9cb9fcef5b 100644 --- a/src/hooks/useCopyableTextRowPress/index.ts +++ b/src/hooks/useCopyableTextRowPress/index.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; -import {useRef} from 'react'; +import {useRef, useState} from 'react'; const COPYABLE_TEXT_SELECTOR = `[data-${CONST.COPYABLE_TEXT_ELEMENT}=true]`; @@ -12,6 +12,10 @@ type HandleCopyableTextRowPressOptions = { shouldCheck?: boolean; }; +type UseCopyableTextRowPressOptions = { + shouldTrackTouchStart?: boolean; +}; + type PressStartOnCopyableTextCache = { clientX: number | undefined; clientY: number | undefined; @@ -144,11 +148,13 @@ function shouldSuppressCopyableTextPress(didMouseDownStartOnCopyableText: boolea return !!anchorCopyableElement || !!focusCopyableElement; } -function useCopyableTextRowPress() { +function useCopyableTextRowPress({shouldTrackTouchStart = false}: UseCopyableTextRowPressOptions = {}) { const wasMouseDownOnCopyableTextRef = useRef(false); const wasTouchStartOnCopyableTextRef = useRef(false); + const didTouchStartOnCopyableTextRef = useRef(false); const shouldSuppressNextPressRef = useRef(false); const pressStartOnCopyableTextCacheRef = useRef(null); + const [didTouchStartOnCopyableText, setDidTouchStartOnCopyableText] = useState(false); const getCachedIsPressStartOnCopyableText = (event: unknown, shouldCheck = true): boolean => { if (!shouldCheck) { @@ -177,7 +183,11 @@ function useCopyableTextRowPress() { pressStartOnCopyableTextCacheRef.current = null; const isCopyableTarget = shouldCheck && isCopyableTextTarget(target); wasMouseDownOnCopyableTextRef.current = isCopyableTarget; + didTouchStartOnCopyableTextRef.current = false; shouldSuppressNextPressRef.current = isCopyableTarget && shouldSuppressNextPress; + if (shouldTrackTouchStart) { + setDidTouchStartOnCopyableText(false); + } return isCopyableTarget; }; @@ -186,10 +196,16 @@ function useCopyableTextRowPress() { const isCopyableTarget = shouldCheck && isCopyableTextTarget(getPressStartTarget(event)); wasMouseDownOnCopyableTextRef.current = false; wasTouchStartOnCopyableTextRef.current = isCopyableTarget; + didTouchStartOnCopyableTextRef.current = isCopyableTarget; shouldSuppressNextPressRef.current = false; + if (shouldTrackTouchStart) { + setDidTouchStartOnCopyableText(isCopyableTarget); + } return isCopyableTarget; }; + const shouldPreserveNativeTextSelection = () => didTouchStartOnCopyableTextRef.current; + const shouldSuppressCopyableTextRowPress = (shouldCheck = true): boolean => { const didPressStartOnCopyableText = wasMouseDownOnCopyableTextRef.current || wasTouchStartOnCopyableTextRef.current; const shouldSuppressPress = shouldCheck && (shouldSuppressNextPressRef.current || shouldSuppressCopyableTextPress(didPressStartOnCopyableText)); @@ -218,10 +234,12 @@ function useCopyableTextRowPress() { const shouldSuppressCopyableTextRowFocus = () => wasMouseDownOnCopyableTextRef.current; return { + didTouchStartOnCopyableText, handleCopyableTextRowPress, isPressStartOnCopyableText: getCachedIsPressStartOnCopyableText, markMouseDownOnCopyableText, markTouchStartOnCopyableText, + shouldPreserveNativeTextSelection, shouldSuppressCopyableTextRowFocus, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress, diff --git a/tests/ui/components/MenuItem/MenuItemCopyTest.tsx b/tests/ui/components/MenuItem/MenuItemCopyTest.tsx index e8f626d88c88..d8ec55fd11fd 100644 --- a/tests/ui/components/MenuItem/MenuItemCopyTest.tsx +++ b/tests/ui/components/MenuItem/MenuItemCopyTest.tsx @@ -69,11 +69,11 @@ jest.mock('@hooks/useResponsiveLayout', () => ({ jest.mock('@libs/getPlatform', () => jest.fn()); jest.mock('@hooks/useCopyableTextRowPress', () => { - const actual = jest.requireActual('@hooks/useCopyableTextRowPress'); + const actual = jest.requireActual('../../../../src/hooks/useCopyableTextRowPress/index.ts'); return {...actual, __esModule: true, default: jest.fn(actual.default)}; }); -const actualUseCopyableTextRowPress = jest.requireActual('@hooks/useCopyableTextRowPress').default; +const actualUseCopyableTextRowPress = jest.requireActual('../../../../src/hooks/useCopyableTextRowPress/index.ts').default; const mockedUseCopyableTextRowPress = jest.mocked(useCopyableTextRowPress); const mockedGetPlatform = jest.mocked(getPlatform); const mockedHasHoverSupport = jest.mocked(hasHoverSupport); @@ -121,11 +121,17 @@ function LegacyRow({isTitleSelectable = false}: {isTitleSelectable?: boolean}) { } function mockCopyableTextHitTest(isPressStartOnCopyableText: (event: unknown) => boolean) { - mockedUseCopyableTextRowPress.mockImplementation(() => ({ - ...actualUseCopyableTextRowPress(), - isPressStartOnCopyableText, - markTouchStartOnCopyableText: (_event, shouldCheck = true) => shouldCheck, - })); + mockedUseCopyableTextRowPress.mockImplementation((options) => { + const hook = actualUseCopyableTextRowPress(options); + const copyableTextElement = document.createElement('span'); + copyableTextElement.setAttribute(`data-${CONST.COPYABLE_TEXT_ELEMENT}`, 'true'); + + return { + ...hook, + isPressStartOnCopyableText, + markTouchStartOnCopyableText: (_event, shouldCheck = true) => hook.markTouchStartOnCopyableText({target: copyableTextElement}, shouldCheck), + }; + }); } /** A long press reaches the row's handler through `PressableWithSecondaryInteraction`. */ diff --git a/tests/unit/hooks/useCopyableTextRowPress.test.ts b/tests/unit/hooks/useCopyableTextRowPress.test.ts index 4ba68ed38366..6aa1891a553f 100644 --- a/tests/unit/hooks/useCopyableTextRowPress.test.ts +++ b/tests/unit/hooks/useCopyableTextRowPress.test.ts @@ -1,4 +1,4 @@ -import {renderHook} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; import type useCopyableTextRowPressModule from '../../../src/hooks/useCopyableTextRowPress/index'; import type {isPressStartOnCopyableText as isPressStartOnCopyableTextModule} from '../../../src/hooks/useCopyableTextRowPress/index'; @@ -69,6 +69,25 @@ describe('useCopyableTextRowPress', () => { expect(result.current.shouldSuppressCopyableTextRowLongPress()).toBe(true); }); + it('tracks copyable touch starts for consumers that need to preserve native text selection', () => { + const {result} = renderHook(() => useCopyableTextRowPress({shouldTrackTouchStart: true})); + const copyableTextElement = getCopyableTextElement(); + + act(() => { + result.current.markTouchStartOnCopyableText({target: copyableTextElement}); + }); + + expect(result.current.didTouchStartOnCopyableText).toBe(true); + expect(result.current.shouldPreserveNativeTextSelection()).toBe(true); + + act(() => { + result.current.markMouseDownOnCopyableText(copyableTextElement); + }); + + expect(result.current.didTouchStartOnCopyableText).toBe(false); + expect(result.current.shouldPreserveNativeTextSelection()).toBe(false); + }); + it('does not suppress row press when the press starts outside copyable text', () => { document.body.innerHTML = 'Regular text'; const regularTextElement = document.getElementById('regularText'); From aa88d580ac74f1d31444c40519cd1f19b08db87d Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 13 Oct 2026 16:35:13 -0400 Subject: [PATCH 581/781] Let Rock read AWS credentials from the SDK default chain Passing only the access key and secret drops AWS_SESSION_TOKEN, so temporary STS credentials from OIDC sessions fail. --- rock.config.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rock.config.mjs b/rock.config.mjs index b6f00dbe3970..42b54180ee2d 100644 --- a/rock.config.mjs +++ b/rock.config.mjs @@ -22,8 +22,7 @@ export default { bucket: 'ad-hoc-expensify-cash', region: 'us-east-1', acl: 'public-read', - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + // Credentials come from the AWS SDK default chain so AWS_SESSION_TOKEN from OIDC sessions is used publicAccess: isPublicAccess, }), bundler: useMetro ? pluginMetro() : pluginRepack(), From fc7fe894bbbc980ed3e9b9af08de226609d5814d Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 13:36:08 -0700 Subject: [PATCH 582/781] fix(app): hide replaced support ticket surveys --- .../configs/visibleReportActions.ts | 31 +++++++++++--- .../OnyxDerived/visibleReportActionsTest.ts | 40 ++++++++++++++++++- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts index c475b058144d..4641d55907a9 100644 --- a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts @@ -5,7 +5,7 @@ import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDer import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions, ReportNameValuePairs} from '@src/types/onyx'; import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; /** @@ -21,7 +21,12 @@ function shouldSkipCachingAction(action: ReportAction): boolean { * actions. Rebuilding the whole map rather than updating individual entries keeps deletions correct: * a removed action is absent from `reportActions`, so it drops out of the result. */ -function computeReportVisibility(reportActions: ReportActions, currentUserAccountID: number | undefined, report: Report | undefined): Record { +function computeReportVisibility( + reportActions: ReportActions, + currentUserAccountID: number | undefined, + report: Report | undefined, + reportNameValuePairs: ReportNameValuePairs | undefined, +): Record { const reportVisibility: Record = {}; const isSupportTicketReport = isSupportTicket(report); @@ -37,6 +42,10 @@ function computeReportVisibility(reportActions: ReportActions, currentUserAccoun if (shouldSkipCachingAction(action)) { continue; } + if (isSupportTicketReport && reportNameValuePairs?.reopenedAsReportID && isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY)) { + reportVisibility[action.reportActionID] = false; + continue; + } reportVisibility[action.reportActionID] = shouldReportActionBeVisible(action, actionID, undefined, currentUserAccountID, undefined, isSupportTicketReport); } @@ -49,8 +58,8 @@ export default createOnyxDerivedValueConfig({ // (for UNREPORTED_TRANSACTION/MOVED_TRANSACTION visibility) AND to provide the current // report collection to the visibility check, avoiding stale data from global connections. // SESSION dependency is needed for whisper targeting when user changes. - dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.SESSION, ONYXKEYS.COLLECTION.REPORT], - compute: ([allReportActions, session, allReports], {sourceValues, currentValue, triggeredKeys}): VisibleReportActionsDerivedValue => { + dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.SESSION, ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS], + compute: ([allReportActions, session, allReports, allReportNameValuePairs], {sourceValues, currentValue, triggeredKeys}): VisibleReportActionsDerivedValue => { if (!allReportActions) { return {}; } @@ -62,7 +71,12 @@ export default createOnyxDerivedValueConfig({ // recompute everything: on first load, when there's no delta, or on a SESSION change (the // user changed, which affects whisper targeting for every report). SESSION is checked via // triggeredKeys, not sourceValues, so a session cleared to `undefined` still forces the full recompute. - const isIncremental = !!reportActionsUpdates && !triggeredKeys?.has(ONYXKEYS.SESSION) && !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT) && !!currentValue; + const isIncremental = + !!reportActionsUpdates && + !triggeredKeys?.has(ONYXKEYS.SESSION) && + !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT) && + !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS) && + !!currentValue; const result: VisibleReportActionsDerivedValue = isIncremental ? {...currentValue} : {}; const reportActionsKeysToProcess = isIncremental ? Object.keys(reportActionsUpdates) : Object.keys(allReportActions); @@ -77,7 +91,12 @@ export default createOnyxDerivedValueConfig({ continue; } - result[reportID] = computeReportVisibility(reportActions, currentUserAccountID, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]); + result[reportID] = computeReportVisibility( + reportActions, + currentUserAccountID, + allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`], + allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`], + ); } return result; diff --git a/tests/unit/OnyxDerived/visibleReportActionsTest.ts b/tests/unit/OnyxDerived/visibleReportActionsTest.ts index 2c4ddc916dc1..342894aa5573 100644 --- a/tests/unit/OnyxDerived/visibleReportActionsTest.ts +++ b/tests/unit/OnyxDerived/visibleReportActionsTest.ts @@ -1,8 +1,9 @@ import visibleReportActionsConfig from '@libs/actions/OnyxDerived/configs/visibleReportActions'; +import CONST from '@src/CONST'; import type {OnyxKey} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportActions} from '@src/types/onyx'; +import type {Report, ReportActions, ReportNameValuePairs} from '@src/types/onyx'; import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; import type {OnyxCollection} from 'react-native-onyx'; @@ -12,7 +13,7 @@ describe('visibleReportActions', () => { const reportActionsKeyA = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}A`; // Only report A has report actions now; report B exists only in the previous derived value (currentValue). const allReportActions: OnyxCollection = {[reportActionsKeyA]: {}}; - const args: Parameters[0] = [allReportActions, undefined, {} as OnyxCollection]; + const args: Parameters[0] = [allReportActions, undefined, {} as OnyxCollection, {} as OnyxCollection]; const currentValue: VisibleReportActionsDerivedValue = {A: {}, B: {}}; const sourceValues: Parameters[1]['sourceValues'] = {[ONYXKEYS.COLLECTION.REPORT_ACTIONS]: {[reportActionsKeyA]: {}}}; @@ -31,4 +32,39 @@ describe('visibleReportActions', () => { expect(result.A).toBeDefined(); }); }); + + it('hides a support survey when the ticket has been replaced', () => { + // Given a cached support survey that was visible before the replacement ticket arrived + const reportID = 'supportTicketReportID'; + const surveyActionID = 'surveyActionID'; + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; + const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; + const reportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`; + const allReportActions: OnyxCollection = { + [reportActionsKey]: { + [surveyActionID]: { + reportActionID: surveyActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY, + created: '2026-10-13 12:00:00.000', + }, + }, + }; + const args: Parameters[0] = [ + allReportActions, + undefined, + {[reportKey]: {reportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}}, + {[reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}}, + ]; + const currentValue: VisibleReportActionsDerivedValue = {[reportID]: {[surveyActionID]: true}}; + + // When the replacement report NVP triggers a recompute + const result = visibleReportActionsConfig.compute(args, { + currentValue, + sourceValues: {[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: {[reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}}}, + triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]), + }); + + // Then the shared visibility cache hides the stale survey + expect(result[reportID]?.[surveyActionID]).toBe(false); + }); }); From b0ce55196fe94fad45b955dc10a1d9fa80ab6702 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 13:36:10 -0700 Subject: [PATCH 583/781] fix(app): seed pending support ticket titles --- src/libs/actions/Report/index.ts | 28 +++++++++++++++++++++++++--- tests/actions/SupportTicketTest.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 263fe23a33f3..ffe1d64a502f 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -4881,13 +4881,35 @@ function isNoSupportRepAvailableResponse(response: {jsonCode?: number | string; function openSupportTicket(resolvedSupportTicketReportID?: string) { const newSupportTicketReportID = resolvedSupportTicketReportID ? undefined : generateReportID(); const parameters: CreateSupportTicketParams = resolvedSupportTicketReportID ? {resolvedSupportTicketReportID, idempotencyKey: Str.guid()} : {newSupportTicketReportID}; + const onyxData: { + optimisticData?: Array>; + failureData?: Array>; + } = newSupportTicketReportID + ? { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: null, + }, + ], + } + : {}; - if (!resolvedSupportTicketReportID) { + // eslint-disable-next-line rulesdir/no-api-side-effects-method -- reopening must wait for the server-selected report ID before navigating. + const request = API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, parameters, onyxData); + if (newSupportTicketReportID) { Navigation.navigate(getReportRouteForCurrentContext({reportID: newSupportTicketReportID, isPendingCreation: true})); } - // eslint-disable-next-line rulesdir/no-api-side-effects-method -- reopening must wait for the server-selected report ID before navigating. - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, parameters).then((response) => { + return request.then((response) => { if (resolvedSupportTicketReportID && response?.reportID) { Navigation.navigate(getReportRouteForCurrentContext({reportID: response.reportID})); } diff --git a/tests/actions/SupportTicketTest.ts b/tests/actions/SupportTicketTest.ts index 970fbcb05c62..29ac8ed333b1 100644 --- a/tests/actions/SupportTicketTest.ts +++ b/tests/actions/SupportTicketTest.ts @@ -7,6 +7,9 @@ import {generateReportID} from '@libs/ReportUtils'; import {isNoSupportRepAvailableResponse, openSupportTicket} from '@userActions/Report'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; jest.mock('@libs/API'); jest.mock('@libs/Navigation/helpers/getReportRouteForCurrentContext'); @@ -42,7 +45,29 @@ describe('actions/Report', () => { expect(mockGenerateReportID).toHaveBeenCalled(); expect(mockGetReportRouteForCurrentContext).toHaveBeenCalledWith({reportID: newSupportTicketReportID, isPendingCreation: true}); expect(mockNavigate).toHaveBeenCalledWith(`r/${newSupportTicketReportID}`); - expect(mockMakeRequestWithSideEffects).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, {newSupportTicketReportID}); + expect(mockMakeRequestWithSideEffects).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, + {newSupportTicketReportID}, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: null, + }, + ], + }, + ); + const requestCallOrder = mockMakeRequestWithSideEffects.mock.invocationCallOrder.at(0) ?? Number.MAX_SAFE_INTEGER; + const navigationCallOrder = mockNavigate.mock.invocationCallOrder.at(0) ?? Number.MIN_SAFE_INTEGER; + expect(requestCallOrder).toBeLessThan(navigationCallOrder); await request; @@ -67,6 +92,7 @@ describe('actions/Report', () => { expect.objectContaining({ resolvedSupportTicketReportID, }), + {}, ); expect(mockMakeRequestWithSideEffects.mock.calls.at(0)?.[1]).not.toHaveProperty('newSupportTicketReportID'); expect(mockGenerateReportID).not.toHaveBeenCalled(); From c93b7bceebb8936675a8c4387bdebf6628fc4ed4 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 13 Oct 2026 16:36:55 -0400 Subject: [PATCH 584/781] Use OIDC for the Rock remote build cache Native build jobs assume app-rock-cache-gh-actions instead of using the static AWS keys. Callers grant id-token: write; write-all keeps every scope the repository default already gave them. --- .github/workflows/buildAndroid.yml | 5 +++-- .github/workflows/buildIOS.yml | 5 +++-- .github/workflows/deploy.yml | 2 ++ .github/workflows/remote-build-android.yml | 6 ++++-- .github/workflows/remote-build-ios.yml | 6 ++++-- .github/workflows/testBuild.yml | 1 + .github/workflows/testBuildOnPush.yml | 1 + .github/workflows/verifyHybridApp.yml | 8 ++++++++ 8 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index de8b5ff08840..86c960c67b3d 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -156,8 +156,9 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + role-duration-seconds: 10800 aws-region: us-east-1 - name: Rock Remote Build - Android diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index 7cdb09c2d8a1..4276b2020ffb 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -237,8 +237,9 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + role-duration-seconds: 10800 aws-region: us-east-1 - name: Prepare provisioning profiles JSON diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eeb9592a36f2..c8bc2f77966f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -201,6 +201,7 @@ jobs: androidBuild: name: Build Android HybridApp needs: [prep] + permissions: write-all if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildAndroid.yml with: @@ -429,6 +430,7 @@ jobs: iosBuild: name: Build iOS HybridApp needs: [prep] + permissions: write-all if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildIOS.yml with: diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml index 7462d1498d15..d48e77add8fa 100644 --- a/.github/workflows/remote-build-android.yml +++ b/.github/workflows/remote-build-android.yml @@ -47,6 +47,7 @@ jobs: build: needs: [prep, resolveRefs] + permissions: write-all runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-2vcpu-ubuntu-2404' }} strategy: fail-fast: false @@ -82,8 +83,9 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + role-duration-seconds: 10800 aws-region: us-east-1 - name: Rock Remote Build - Android diff --git a/.github/workflows/remote-build-ios.yml b/.github/workflows/remote-build-ios.yml index 482f8a1d32f6..32083380cc47 100644 --- a/.github/workflows/remote-build-ios.yml +++ b/.github/workflows/remote-build-ios.yml @@ -47,6 +47,7 @@ jobs: build: needs: [prep, resolveRefs] + permissions: write-all runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }} strategy: fail-fast: false @@ -94,8 +95,9 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + role-duration-seconds: 10800 aws-region: us-east-1 - name: Rock Remote Build - iOS diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index c58bffcfb075..b42d6fa5ad35 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -63,6 +63,7 @@ jobs: buildApps: needs: [prep, resolveRefs] + permissions: write-all uses: ./.github/workflows/buildAdHoc.yml with: # Fall back to App main when nothing is resolved (test builds default to the latest NewDot). diff --git a/.github/workflows/testBuildOnPush.yml b/.github/workflows/testBuildOnPush.yml index b7f3d20e3d0b..068960e457b4 100644 --- a/.github/workflows/testBuildOnPush.yml +++ b/.github/workflows/testBuildOnPush.yml @@ -70,6 +70,7 @@ jobs: buildApps: needs: [prep] + permissions: write-all uses: ./.github/workflows/buildAdHoc.yml with: APP_REF: ${{ needs.prep.outputs.APP_REF }} diff --git a/.github/workflows/verifyHybridApp.yml b/.github/workflows/verifyHybridApp.yml index f16b9f77ac5d..762f08d95427 100644 --- a/.github/workflows/verifyHybridApp.yml +++ b/.github/workflows/verifyHybridApp.yml @@ -75,6 +75,10 @@ jobs: verify_android: name: Verify Android HybridApp builds on main + permissions: + contents: read + id-token: write + pull-requests: write if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} cache-mode: read uses: ./.github/workflows/buildAndroid.yml @@ -85,6 +89,10 @@ jobs: verify_ios: name: Verify iOS HybridApp builds on main + permissions: + contents: read + id-token: write + pull-requests: write if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} cache-mode: read uses: ./.github/workflows/buildIOS.yml From 1a39317b8f54fc4b5da297a8074e9dcb0b2c90ad Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 13 Oct 2026 16:37:13 -0400 Subject: [PATCH 585/781] Use OIDC for ad-hoc web deploys --- .github/workflows/buildAdHoc.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml index 3633c87fb9cb..9aa04f3f7089 100644 --- a/.github/workflows/buildAdHoc.yml +++ b/.github/workflows/buildAdHoc.yml @@ -127,8 +127,8 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-adhoc-web-deploy-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} aws-region: us-east-1 - name: Deploy to S3 From 2964a8006d8a102b6479495c560aacf8a20820aa Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 13 Oct 2026 16:38:15 -0400 Subject: [PATCH 586/781] Use OIDC for staging and production web deploys --- .github/workflows/deploy.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c8bc2f77966f..63ace45e33ef 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -705,6 +705,7 @@ jobs: name: Deploy Web to S3 needs: [prep, webBuild, buildStorybook] if: ${{ always() && needs.webBuild.result == 'success' }} + permissions: write-all runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout @@ -737,8 +738,8 @@ jobs: # v6 uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: arn:aws:iam::949552126497:role/app-${{ env.DEPLOY_ENV }}-web-deploy-gh-actions + role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} aws-region: us-east-1 - name: Deploy to S3 From c6d33043b40e18c9de366973e5e99c74f7f0313b Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 13:45:33 -0700 Subject: [PATCH 587/781] docs(app): explain replaced survey visibility --- src/libs/actions/OnyxDerived/configs/visibleReportActions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts index 4641d55907a9..724cebe60221 100644 --- a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts @@ -42,6 +42,7 @@ function computeReportVisibility( if (shouldSkipCachingAction(action)) { continue; } + // A replacement ticket has its own active survey, so the original ticket's survey is stale. if (isSupportTicketReport && reportNameValuePairs?.reopenedAsReportID && isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY)) { reportVisibility[action.reportActionID] = false; continue; From 47f6780feae71a334420fec5a224815fd71e2759 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 13:45:34 -0700 Subject: [PATCH 588/781] docs(app): explain support ticket request ordering --- src/libs/actions/Report/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index ffe1d64a502f..9d7f3d658bda 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -4903,7 +4903,8 @@ function openSupportTicket(resolvedSupportTicketReportID?: string) { } : {}; - // eslint-disable-next-line rulesdir/no-api-side-effects-method -- reopening must wait for the server-selected report ID before navigating. + // Start the request before navigating so the optimistic support-ticket metadata is available for the pending LHN title. + // eslint-disable-next-line rulesdir/no-api-side-effects-method -- Reopening must wait for the server-selected report ID before navigating. const request = API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_SUPPORT_TICKET, parameters, onyxData); if (newSupportTicketReportID) { Navigation.navigate(getReportRouteForCurrentContext({reportID: newSupportTicketReportID, isPendingCreation: true})); From 339d25e4fd14cf19fb63ef3bb6f43f9ab3066d61 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Tue, 13 Oct 2026 22:55:41 +0200 Subject: [PATCH 589/781] Adjust tests --- tests/ui/WeekListItemHeaderTest.tsx | 1 + tests/unit/getInProgressBucketLabelTest.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx index 85eba37f157a..6588f4b61781 100644 --- a/tests/ui/WeekListItemHeaderTest.tsx +++ b/tests/ui/WeekListItemHeaderTest.tsx @@ -75,6 +75,7 @@ const createWeekListItem = (week: string, options: Partial { week, formattedWeek: DateUtils.getFormattedDateRangeForSearch(start, end, undefined), shortFormattedWeek: DateUtils.getShortFormattedDateRangeForSearch(start, end, undefined), + compactFormattedWeek: DateUtils.getCompactFormattedDateForSearch(start, undefined), }; }; const quarterGroup = (year: number, quarter: number): GroupedItem => ({ @@ -47,6 +48,7 @@ const dayGroup = (day: string): GroupedItem => ({ day, formattedDay: DateUtils.formatToReadableString(day, undefined), shortFormattedDay: DateUtils.getShortFormattedDayForSearch(day, undefined), + compactFormattedDay: DateUtils.getCompactFormattedDateForSearch(day, undefined), }); const merchantGroup = (merchant: string): GroupedItem => ({...BASE, groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, merchant, formattedMerchant: merchant}); From 5ef360059c71955cf84d44fa8a1603e9e0beba0e Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 14:18:20 -0700 Subject: [PATCH 590/781] fix(app): keep visibility recomputation incremental --- .../configs/visibleReportActions.ts | 26 ++++++++++--------- .../OnyxDerived/visibleReportActionsTest.ts | 8 +++++- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts index 724cebe60221..6507142a463c 100644 --- a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts @@ -67,23 +67,25 @@ export default createOnyxDerivedValueConfig({ const currentUserAccountID = session?.accountID; const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS]; + const reportNameValuePairsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]; - // Recompute only the reports whose actions changed when we have a usable delta. Otherwise - // recompute everything: on first load, when there's no delta, or on a SESSION change (the - // user changed, which affects whisper targeting for every report). SESSION is checked via - // triggeredKeys, not sourceValues, so a session cleared to `undefined` still forces the full recompute. + // Recompute only the reports whose actions or NVPs changed when we have a usable delta. + // Otherwise recompute everything: on first load, when there's no delta, or on a SESSION or + // REPORT change. SESSION is checked via triggeredKeys, not sourceValues, so a session cleared + // to `undefined` still forces the full recompute. const isIncremental = - !!reportActionsUpdates && - !triggeredKeys?.has(ONYXKEYS.SESSION) && - !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT) && - !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS) && - !!currentValue; + !!(reportActionsUpdates ?? reportNameValuePairsUpdates) && !triggeredKeys?.has(ONYXKEYS.SESSION) && !triggeredKeys?.has(ONYXKEYS.COLLECTION.REPORT) && !!currentValue; const result: VisibleReportActionsDerivedValue = isIncremental ? {...currentValue} : {}; - const reportActionsKeysToProcess = isIncremental ? Object.keys(reportActionsUpdates) : Object.keys(allReportActions); + const reportIDsToProcess = isIncremental + ? new Set([ + ...Object.keys(reportActionsUpdates ?? {}).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, '')), + ...Object.keys(reportNameValuePairsUpdates ?? {}).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, '')), + ]) + : new Set(Object.keys(allReportActions).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''))); - for (const reportActionsKey of reportActionsKeysToProcess) { - const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + for (const reportID of reportIDsToProcess) { + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; const reportActions = allReportActions[reportActionsKey]; // The member was removed entirely — drop the report from the result. diff --git a/tests/unit/OnyxDerived/visibleReportActionsTest.ts b/tests/unit/OnyxDerived/visibleReportActionsTest.ts index 342894aa5573..8af546c90fef 100644 --- a/tests/unit/OnyxDerived/visibleReportActionsTest.ts +++ b/tests/unit/OnyxDerived/visibleReportActionsTest.ts @@ -40,6 +40,8 @@ describe('visibleReportActions', () => { const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; const reportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`; + const unrelatedReportID = 'unrelatedReportID'; + const unrelatedActionID = 'unrelatedActionID'; const allReportActions: OnyxCollection = { [reportActionsKey]: { [surveyActionID]: { @@ -55,7 +57,10 @@ describe('visibleReportActions', () => { {[reportKey]: {reportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}}, {[reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}}, ]; - const currentValue: VisibleReportActionsDerivedValue = {[reportID]: {[surveyActionID]: true}}; + const currentValue: VisibleReportActionsDerivedValue = { + [reportID]: {[surveyActionID]: true}, + [unrelatedReportID]: {[unrelatedActionID]: true}, + }; // When the replacement report NVP triggers a recompute const result = visibleReportActionsConfig.compute(args, { @@ -66,5 +71,6 @@ describe('visibleReportActions', () => { // Then the shared visibility cache hides the stale survey expect(result[reportID]?.[surveyActionID]).toBe(false); + expect(result[unrelatedReportID]?.[unrelatedActionID]).toBe(true); }); }); From 39896144e8945cbac5669a8bb780fce1782ab035 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 14:18:28 -0700 Subject: [PATCH 591/781] fix(app): keep pending support tickets local --- src/libs/actions/Report/index.ts | 23 +++++++++++++++-------- tests/actions/SupportTicketTest.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 9d7f3d658bda..32d6b959b634 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -4888,9 +4888,9 @@ function openSupportTicket(resolvedSupportTicketReportID?: string) { ? { optimisticData: [ { - onyxMethod: Onyx.METHOD.SET, + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, - value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + value: {type: CONST.REPORT.TYPE.SUPPORT_TICKET}, }, ], failureData: [ @@ -4910,12 +4910,19 @@ function openSupportTicket(resolvedSupportTicketReportID?: string) { Navigation.navigate(getReportRouteForCurrentContext({reportID: newSupportTicketReportID, isPendingCreation: true})); } - return request.then((response) => { - if (resolvedSupportTicketReportID && response?.reportID) { - Navigation.navigate(getReportRouteForCurrentContext({reportID: response.reportID})); - } - return response; - }); + return request + .then((response) => { + if (resolvedSupportTicketReportID && response?.reportID) { + Navigation.navigate(getReportRouteForCurrentContext({reportID: response.reportID})); + } + return response; + }) + .catch(async (error: unknown) => { + if (onyxData.failureData) { + await Onyx.update(onyxData.failureData); + } + throw error; + }); } function dismissFailedSupportTicket(supportTicketReportID: string, parentReportID: string, parentReportActionID: string) { diff --git a/tests/actions/SupportTicketTest.ts b/tests/actions/SupportTicketTest.ts index 29ac8ed333b1..08544cd8adf2 100644 --- a/tests/actions/SupportTicketTest.ts +++ b/tests/actions/SupportTicketTest.ts @@ -36,6 +36,10 @@ describe('actions/Report', () => { mockNavigate.mockClear(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('opens a pending report and sends the expected payload for a new support ticket', async () => { // Given a request for a new support ticket // When the customer asks to talk to a human @@ -51,9 +55,9 @@ describe('actions/Report', () => { { optimisticData: [ { - onyxMethod: Onyx.METHOD.SET, + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, - value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + value: {type: CONST.REPORT.TYPE.SUPPORT_TICKET}, }, ], failureData: [ @@ -75,6 +79,26 @@ describe('actions/Report', () => { expect(mockNavigate).toHaveBeenCalledTimes(1); }); + it('removes the pending report when the request fails before receiving a response', async () => { + // Given a request that fails before the API can apply its failure data + const networkError = new Error('Network request failed'); + const onyxUpdateSpy = jest.spyOn(Onyx, 'update').mockResolvedValue(undefined); + mockMakeRequestWithSideEffects.mockRejectedValueOnce(networkError); + + // When the customer asks to talk to a human + const request = openSupportTicket(); + + // Then the App removes the pending report and preserves the request failure + await expect(request).rejects.toBe(networkError); + expect(onyxUpdateSpy).toHaveBeenCalledWith([ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: null, + }, + ]); + }); + it('navigates to a newly reassigned support ticket', async () => { // Given a resolved support ticket and a server response for its reassignment const resolvedSupportTicketReportID = 'resolvedSupportTicketReportID'; From 866cbbf1bc1669b8d816a0953802117f4c5bb98f Mon Sep 17 00:00:00 2001 From: X Developer Date: Tue, 13 Oct 2026 23:25:07 +0200 Subject: [PATCH 592/781] Centralize copyable row press-in handling --- src/components/MenuItem/MenuItem.tsx | 18 +++++++---------- .../MenuItem/layout/MenuItemRoot.tsx | 20 +++++++------------ .../useCopyableTextRowPress/index.native.ts | 9 +++++++-- src/hooks/useCopyableTextRowPress/index.ts | 20 +++++++++++++++++-- .../hooks/useCopyableTextRowPress.test.ts | 9 +++++++-- 5 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 851e177f5b4f..289ab898edcc 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -593,10 +593,10 @@ function MenuItem({ const pressableRef = useRef>(null); const { didTouchStartOnCopyableText, + handleCopyableTextRowPressIn, isPressStartOnCopyableText, markMouseDownOnCopyableText, markTouchStartOnCopyableText, - shouldPreserveNativeTextSelection, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress, } = useCopyableTextRowPress({shouldTrackTouchStart: true}); @@ -807,15 +807,6 @@ function MenuItem({ }; const secondaryInteractionHandler = copyable && !deviceHasHoverSupport ? secondaryInteraction : onSecondaryInteraction; - const handlePressIn = () => { - if (shouldEnableTextSelection && shouldPreserveNativeTextSelection()) { - return; - } - if (shouldBlockSelection && shouldUseNarrowLayout && canUseTouchScreen()) { - ControlSelection.block(); - } - }; - const isIDPassed = !!iconReportID || !!iconAccountID || iconAccountID === CONST.DEFAULT_NUMBER_ID; // A known account renders straight from the personal-details context. Only the report-derived avatars need `ReportAvatar`. @@ -859,7 +850,12 @@ function MenuItem({ }} shouldAllowTextSelection={isTitleSelectable} preventDefaultContextMenu={(event) => deviceHasHoverSupport || !isTitleSelectable || !isPressStartOnCopyableText(event)} - onPressIn={handlePressIn} + onPressIn={() => + handleCopyableTextRowPressIn({ + shouldEnableTextSelection, + shouldBlockSelection: shouldBlockSelection && shouldUseNarrowLayout && canUseTouchScreen(), + }) + } onPressOut={ControlSelection.unblock} // Keep the native selection menu available through press-out; reset on the next pointer start. onSecondaryInteraction={shouldEnableTextSelection && didTouchStartOnCopyableText ? undefined : secondaryInteractionHandler} diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index 4ac44215d566..f0edb3e0b507 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -66,10 +66,10 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI const shouldEnableTextSelection = shouldAllowTextSelection && getPlatform() === CONST.PLATFORM.WEB; const { didTouchStartOnCopyableText, + handleCopyableTextRowPressIn, isPressStartOnCopyableText, markMouseDownOnCopyableText, markTouchStartOnCopyableText, - shouldPreserveNativeTextSelection, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress, } = useCopyableTextRowPress({shouldTrackTouchStart: true}); @@ -109,17 +109,6 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI } : undefined; - const handlePressIn = () => { - // RN Web responder events omit client coordinates, so reuse the original touch hit-test. - if (shouldEnableTextSelection && shouldPreserveNativeTextSelection()) { - return; - } - - if (onSecondaryInteractionAction && shouldUseNarrowLayout && canUseTouchScreen()) { - ControlSelection.block(); - } - }; - return ( @@ -134,7 +123,12 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI }} shouldAllowTextSelection={shouldEnableTextSelection} preventDefaultContextMenu={(event) => !shouldEnableTextSelection || !isPressStartOnCopyableText(event)} - onPressIn={handlePressIn} + onPressIn={() => + handleCopyableTextRowPressIn({ + shouldEnableTextSelection, + shouldBlockSelection: !!onSecondaryInteractionAction && shouldUseNarrowLayout && canUseTouchScreen(), + }) + } onPressOut={ControlSelection.unblock} // RN Web prevents the native context menu whenever an onLongPress handler is attached. // Reset on the next pointer start, since press-out can precede the browser's selection menu. diff --git a/src/hooks/useCopyableTextRowPress/index.native.ts b/src/hooks/useCopyableTextRowPress/index.native.ts index 38833ef42b05..0ed3d818b772 100644 --- a/src/hooks/useCopyableTextRowPress/index.native.ts +++ b/src/hooks/useCopyableTextRowPress/index.native.ts @@ -6,25 +6,30 @@ type HandleCopyableTextRowPressOptions = { shouldCheck?: boolean; }; +type HandleCopyableTextRowPressInOptions = { + shouldEnableTextSelection?: boolean; + shouldBlockSelection?: boolean; +}; + const isPressStartOnCopyableText = () => false; function useCopyableTextRowPress() { // Native does not expose browser selection APIs, so copyable text never suppresses row interactions here. const markMouseDownOnCopyableText: (target: EventTarget | null | undefined, shouldCheck?: boolean, options?: MarkCopyableTextMouseDownOptions) => boolean = () => false; const markTouchStartOnCopyableText: (event: unknown, shouldCheck?: boolean) => boolean = () => false; - const shouldPreserveNativeTextSelection = () => false; const shouldSuppressCopyableTextRowPress: (shouldCheck?: boolean) => boolean = () => false; const shouldSuppressCopyableTextRowLongPress: (shouldCheck?: boolean) => boolean = () => false; const shouldSuppressCopyableTextRowFocus = () => false; const handleCopyableTextRowPress: (onPress: () => void, options?: HandleCopyableTextRowPressOptions) => void = (onPress) => onPress(); + const handleCopyableTextRowPressIn: (options?: HandleCopyableTextRowPressInOptions) => void = () => {}; return { didTouchStartOnCopyableText: false, handleCopyableTextRowPress, + handleCopyableTextRowPressIn, isPressStartOnCopyableText, markMouseDownOnCopyableText, markTouchStartOnCopyableText, - shouldPreserveNativeTextSelection, shouldSuppressCopyableTextRowFocus, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress, diff --git a/src/hooks/useCopyableTextRowPress/index.ts b/src/hooks/useCopyableTextRowPress/index.ts index 6e9cb9fcef5b..d42d6a3762e8 100644 --- a/src/hooks/useCopyableTextRowPress/index.ts +++ b/src/hooks/useCopyableTextRowPress/index.ts @@ -1,3 +1,5 @@ +import ControlSelection from '@libs/ControlSelection'; + import CONST from '@src/CONST'; import {useRef, useState} from 'react'; @@ -12,6 +14,11 @@ type HandleCopyableTextRowPressOptions = { shouldCheck?: boolean; }; +type HandleCopyableTextRowPressInOptions = { + shouldEnableTextSelection?: boolean; + shouldBlockSelection?: boolean; +}; + type UseCopyableTextRowPressOptions = { shouldTrackTouchStart?: boolean; }; @@ -204,7 +211,16 @@ function useCopyableTextRowPress({shouldTrackTouchStart = false}: UseCopyableTex return isCopyableTarget; }; - const shouldPreserveNativeTextSelection = () => didTouchStartOnCopyableTextRef.current; + // RN Web press-in events omit client coordinates, so reuse the original touch hit-test before blocking selection. + const handleCopyableTextRowPressIn = ({shouldEnableTextSelection = false, shouldBlockSelection = false}: HandleCopyableTextRowPressInOptions = {}) => { + if (shouldEnableTextSelection && didTouchStartOnCopyableTextRef.current) { + return; + } + + if (shouldBlockSelection) { + ControlSelection.block(); + } + }; const shouldSuppressCopyableTextRowPress = (shouldCheck = true): boolean => { const didPressStartOnCopyableText = wasMouseDownOnCopyableTextRef.current || wasTouchStartOnCopyableTextRef.current; @@ -236,10 +252,10 @@ function useCopyableTextRowPress({shouldTrackTouchStart = false}: UseCopyableTex return { didTouchStartOnCopyableText, handleCopyableTextRowPress, + handleCopyableTextRowPressIn, isPressStartOnCopyableText: getCachedIsPressStartOnCopyableText, markMouseDownOnCopyableText, markTouchStartOnCopyableText, - shouldPreserveNativeTextSelection, shouldSuppressCopyableTextRowFocus, shouldSuppressCopyableTextRowLongPress, shouldSuppressCopyableTextRowPress, diff --git a/tests/unit/hooks/useCopyableTextRowPress.test.ts b/tests/unit/hooks/useCopyableTextRowPress.test.ts index 6aa1891a553f..fd8510e5cc62 100644 --- a/tests/unit/hooks/useCopyableTextRowPress.test.ts +++ b/tests/unit/hooks/useCopyableTextRowPress.test.ts @@ -1,5 +1,7 @@ import {act, renderHook} from '@testing-library/react-native'; +import ControlSelection from '@libs/ControlSelection'; + import type useCopyableTextRowPressModule from '../../../src/hooks/useCopyableTextRowPress/index'; import type {isPressStartOnCopyableText as isPressStartOnCopyableTextModule} from '../../../src/hooks/useCopyableTextRowPress/index'; @@ -72,20 +74,23 @@ describe('useCopyableTextRowPress', () => { it('tracks copyable touch starts for consumers that need to preserve native text selection', () => { const {result} = renderHook(() => useCopyableTextRowPress({shouldTrackTouchStart: true})); const copyableTextElement = getCopyableTextElement(); + const blockSelectionSpy = jest.spyOn(ControlSelection, 'block').mockImplementation(); act(() => { result.current.markTouchStartOnCopyableText({target: copyableTextElement}); }); expect(result.current.didTouchStartOnCopyableText).toBe(true); - expect(result.current.shouldPreserveNativeTextSelection()).toBe(true); + result.current.handleCopyableTextRowPressIn({shouldEnableTextSelection: true, shouldBlockSelection: true}); + expect(blockSelectionSpy).not.toHaveBeenCalled(); act(() => { result.current.markMouseDownOnCopyableText(copyableTextElement); }); expect(result.current.didTouchStartOnCopyableText).toBe(false); - expect(result.current.shouldPreserveNativeTextSelection()).toBe(false); + result.current.handleCopyableTextRowPressIn({shouldEnableTextSelection: true, shouldBlockSelection: true}); + expect(blockSelectionSpy).toHaveBeenCalledTimes(1); }); it('does not suppress row press when the press starts outside copyable text', () => { From 36f3b2982e6a87b9a5deb4f310e6db6acf514f0a Mon Sep 17 00:00:00 2001 From: Olgierd Date: Tue, 13 Oct 2026 23:35:14 +0200 Subject: [PATCH 593/781] On mobile Spend over time labels only the first and last x-axis buckets --- src/components/Search/SearchChartView.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 4f54fa1a892b..b9e76bd8a2eb 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -3,6 +3,7 @@ import type {ChartDataPoint} from '@components/Charts'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -74,6 +75,7 @@ function SearchChartView({ chartContainerStyle, }: SearchChartViewProps) { const {preferredLocale, translate, dateFnsLocale} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const {currentSearchKey} = useSearchQueryContext(); @@ -91,6 +93,8 @@ function SearchChartView({ }); const points = rows.map((row) => row.point); + const labelSteps = shouldUseNarrowLayout && axisLabelSteps ? [Math.max(1, points.length - 1)] : axisLabelSteps; + const handleItemPress = (dataPoint: ChartDataPoint, index: number) => { const item = rows.at(index)?.item; if (!item || !queryJSON) { @@ -144,7 +148,7 @@ function SearchChartView({ series={series} isLoading={isLoading} onPointPress={onItemPress} - labelSteps={axisLabelSteps} + labelSteps={labelSteps} yAxisUnit={unit} yAxisUnitPosition={unitPosition} /> From a25192600794653fbaff61d14f1f4932f883eba1 Mon Sep 17 00:00:00 2001 From: aimane-chnaif Date: Tue, 13 Oct 2026 23:05:30 +0100 Subject: [PATCH 594/781] Restore Latest messages pill after collapsing composer --- src/hooks/useReportActionsScroll.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index fb35636a4f6d..2ac0dbee8415 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -241,6 +241,18 @@ function useReportActionsScroll({ shouldBeAlignedToTop, }); + const prevIsComposerFullSize = usePrevious(isComposerFullSize); + // Scroll events skipped pill tracking while expanded, so sync the Latest messages pill with the current offset + const syncPillAfterComposerCollapse = useEffectEvent(() => { + if (!prevIsComposerFullSize || isComposerFullSize || shouldDisablePillTracking) { + return; + } + updatePillVisibility(); + }); + useEffect(() => { + syncPillAfterComposerCollapse(); + }, [isComposerFullSize]); + const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom} = useReportActionsNewActionLiveTail({ conciergeChat, reportID, From 8098a6a235d3001f286bf198caaf2be2b97d9a96 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 15:21:41 -0700 Subject: [PATCH 595/781] fix(support): acknowledge support survey reactions --- .../ConciergeFeedbackPrompt.tsx | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index 237a2ed3adf7..fe680a432b8c 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -136,10 +136,12 @@ function ConciergeFeedbackPrompt({ const thumbsDown = findEmojiByName('-1'); const thumbsUpReactedAt = getReactedAtTimestamp(thumbsUp, reactions, currentUserAccountID); + const thumbsDownReactedAt = getReactedAtTimestamp(thumbsDown, reactions, currentUserAccountID); + const latestReactionTimestamp = Math.max(thumbsUpReactedAt ?? 0, thumbsDownReactedAt ?? 0) || undefined; // The reaction that was already there when this row mounted is an old rating, so only a reaction that lands while the row is open is acknowledged - const [mountedWithThumbsUpReactedAt] = useState(thumbsUpReactedAt); - const hasJustReacted = thumbsUpReactedAt !== undefined && thumbsUpReactedAt !== mountedWithThumbsUpReactedAt; + const [mountedWithLatestReactionTimestamp] = useState(latestReactionTimestamp); + const hasJustReacted = latestReactionTimestamp !== undefined && latestReactionTimestamp !== mountedWithLatestReactionTimestamp; // The debounced copy catches up once the window has passed, which is what takes the acknowledgement back down const hasSettledAfterReacting = useDebouncedValue(hasJustReacted, THANKS_VISIBLE_DURATION_MS); @@ -152,15 +154,6 @@ function ConciergeFeedbackPrompt({ const hasRated = hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID) || hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID); - // The acknowledgement comes from the reaction, so removing it from the reaction row brings the prompt back right away - if (!shouldPersistAfterRating && isDisplayedThankMessage) { - return {translate('concierge.feedback.thanks')}; - } - - if (!shouldPersistAfterRating && hasRated) { - return null; - } - const feedbackThumbs = ( ); + const selectedEmoji = thumbsUpReactedAt ? thumbsUp : thumbsDown; + const selectedFeedbackThumb = ( + rate(selectedEmoji))} + shouldShowTooltip={shouldShowTooltips} + isSelected + /> + ); + + // The acknowledgement comes from the reaction, so removing it from the reaction row brings the prompt back right away + if (!shouldPersistAfterRating && isDisplayedThankMessage) { + return {translate('concierge.feedback.thanks')}; + } + + if (!shouldPersistAfterRating && hasRated) { + return null; + } + + if (shouldPersistAfterRating && hasRated) { + return ( + + {isDisplayedThankMessage && {translate('concierge.feedback.thanks')}} + {selectedFeedbackThumb} + + ); + } + if (shouldRenderInline) { return feedbackThumbs; } From 57fbb502885eee87740526bf17c8e4139fc0ab51 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 15:29:03 -0700 Subject: [PATCH 596/781] refactor(support): share survey feedback thumbs --- .../ConciergeFeedbackPrompt.tsx | 42 +++++++------------ 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index fe680a432b8c..c65269ae59c5 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -137,6 +137,8 @@ function ConciergeFeedbackPrompt({ const thumbsUpReactedAt = getReactedAtTimestamp(thumbsUp, reactions, currentUserAccountID); const thumbsDownReactedAt = getReactedAtTimestamp(thumbsDown, reactions, currentUserAccountID); + + // Either rating starts the acknowledgement window, and the zero fallback keeps an unanswered survey undefined. const latestReactionTimestamp = Math.max(thumbsUpReactedAt ?? 0, thumbsDownReactedAt ?? 0) || undefined; // The reaction that was already there when this row mounted is an old rating, so only a reaction that lands while the row is open is acknowledged @@ -154,38 +156,26 @@ function ConciergeFeedbackPrompt({ const hasRated = hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID) || hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID); + const getFeedbackThumb = (emoji: Emoji, isSelected: boolean) => ( + rate(emoji))} + shouldShowTooltip={shouldShowTooltips} + isSelected={isSelected} + /> + ); + const feedbackThumbs = ( - rate(thumbsUp))} - shouldShowTooltip={shouldShowTooltips} - isSelected={hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID)} - /> - rate(thumbsDown))} - shouldShowTooltip={shouldShowTooltips} - isSelected={hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID)} - /> + {getFeedbackThumb(thumbsUp, hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID))} + {getFeedbackThumb(thumbsDown, hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID))} ); const selectedEmoji = thumbsUpReactedAt ? thumbsUp : thumbsDown; - const selectedFeedbackThumb = ( - rate(selectedEmoji))} - shouldShowTooltip={shouldShowTooltips} - isSelected - /> - ); + const selectedFeedbackThumb = getFeedbackThumb(selectedEmoji, true); // The acknowledgement comes from the reaction, so removing it from the reaction row brings the prompt back right away if (!shouldPersistAfterRating && isDisplayedThankMessage) { From 71d4f2874671b9dafed4a1420ced9a5c3d2e3251 Mon Sep 17 00:00:00 2001 From: X Developer Date: Wed, 14 Oct 2026 00:31:31 +0200 Subject: [PATCH 597/781] Preserve formatting in multi-field copied selections --- src/libs/SelectionScraper/index.ts | 110 ++++++++++++++++++++++++----- tests/unit/SelectionScraperTest.ts | 26 +++++++ 2 files changed, 117 insertions(+), 19 deletions(-) diff --git a/src/libs/SelectionScraper/index.ts b/src/libs/SelectionScraper/index.ts index 01d7ec6c5c29..d1f84cc46845 100644 --- a/src/libs/SelectionScraper/index.ts +++ b/src/libs/SelectionScraper/index.ts @@ -22,10 +22,50 @@ const COPYABLE_ROW_DATA_SET = {[CONST.COPYABLE_ROW_ELEMENT]: true} as const; const copyableRowContinuationAttribute = `data-${CONST.COPYABLE_ROW_CONTINUATION_ELEMENT}`; const COPYABLE_ROW_CONTINUATION_DATA_SET = {...COPYABLE_ROW_DATA_SET, [CONST.COPYABLE_ROW_CONTINUATION_ELEMENT]: true} as const; -function getCopyableElementText(element: globalThis.Element, selection: Selection): string { +function normalizeSelectedContent(content: globalThis.DocumentFragment) { + const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); + const textNodes: globalThis.Text[] = []; + let textNode = walker.nextNode(); + let hasText = false; + let previousTextEndsWithSpace = false; + + while (textNode) { + if (textNode instanceof globalThis.Text) { + textNode.data = textNode.data.replaceAll(/[\t\n\r ]+/g, ' '); + if (!hasText || previousTextEndsWithSpace) { + textNode.data = textNode.data.trimStart(); + } + if (textNode.data) { + hasText = true; + previousTextEndsWithSpace = textNode.data.endsWith(' '); + } + textNodes.push(textNode); + } + textNode = walker.nextNode(); + } + + const lastTextNode = textNodes.findLast((node) => !!node.data); + if (lastTextNode) { + lastTextNode.data = lastTextNode.data.trimEnd(); + } +} + +function unwrapNonSemanticElements(content: globalThis.DocumentFragment) { + for (const element of content.querySelectorAll('*')) { + const renderedTag = element.getAttribute(tagAttribute); + const nativeTag = element.tagName.toLowerCase(); + if ((renderedTag && markdownElements.has(renderedTag)) || markdownElements.has(nativeTag)) { + continue; + } + + element.replaceWith(...Array.from(element.childNodes)); + } +} + +function getCopyableElementContent(element: globalThis.Element, selection: Selection): globalThis.DocumentFragment { const elementRange = document.createRange(); elementRange.selectNodeContents(element); - const selectedText: string[] = []; + const selectedContent = document.createDocumentFragment(); for (let i = 0; i < selection.rangeCount; i++) { const intersectionRange = selection.getRangeAt(i).cloneRange(); @@ -39,23 +79,44 @@ function getCopyableElementText(element: globalThis.Element, selection: Selectio if (intersectionRange.compareBoundaryPoints(globalThis.Range.END_TO_END, elementRange) > 0) { intersectionRange.setEnd(elementRange.endContainer, elementRange.endOffset); } - selectedText.push(intersectionRange.toString()); + + let content: globalThis.Node = intersectionRange.cloneContents(); + let parent = getElementFromNode(intersectionRange.commonAncestorContainer)?.closest(`[${tagAttribute}]`) ?? null; + while (parent && element.contains(parent)) { + const clonedParent = parent.cloneNode(); + clonedParent.appendChild(content); + content = clonedParent; + parent = parent.parentElement?.closest(`[${tagAttribute}]`) ?? null; + } + + const fragment = document.createDocumentFragment(); + fragment.appendChild(content); + for (const hiddenElement of fragment.querySelectorAll(hiddenElementSelector)) { + hiddenElement.remove(); + } + unwrapNonSemanticElements(fragment); + normalizeSelectedContent(fragment); + if (!fragment.textContent?.trim()) { + continue; + } + + if (selectedContent.textContent) { + selectedContent.appendChild(document.createTextNode(' ')); + } + selectedContent.appendChild(fragment); } - return selectedText - .join(' ') - .trim() - .replaceAll(/[\t\n\r ]+/g, ' '); + return selectedContent; } -function replaceElementContentWithLines(element: globalThis.Element, lines: Array<{row: globalThis.Element; text: string}>) { - for (const [index, {text}] of lines.entries()) { +function replaceElementContentWithLines(element: globalThis.Element, lines: Array<{row: globalThis.Element; content: globalThis.DocumentFragment}>) { + for (const [index, {content}] of lines.entries()) { if (index > 0) { const previousRow = lines.at(index - 1)?.row; const separator = previousRow?.getAttribute(copyableRowContinuationAttribute) === 'true' ? document.createTextNode(' ') : document.createElement('br'); element.appendChild(separator); } - element.appendChild(document.createTextNode(text)); + element.appendChild(content); } } @@ -171,15 +232,26 @@ function getHTMLOfSelectedCopyableRows(selection: Selection): string { } const lines = selectedRows - .map((row) => ({ - row, - text: [row, ...Array.from(row.querySelectorAll(COPYABLE_TEXT_SELECTOR))] - .filter((copyableElement) => selectedCopyableElementsByRow.get(row)?.has(copyableElement)) - .map((copyableElement) => getCopyableElementText(copyableElement, selection)) - .filter((text) => !!text) - .join(' '), - })) - .filter(({text}) => !!text); + .map((row) => { + const content = document.createDocumentFragment(); + for (const copyableElement of [row, ...Array.from(row.querySelectorAll(COPYABLE_TEXT_SELECTOR))]) { + if (!selectedCopyableElementsByRow.get(row)?.has(copyableElement)) { + continue; + } + + const copyableContent = getCopyableElementContent(copyableElement, selection); + if (!copyableContent.textContent) { + continue; + } + + if (content.textContent) { + content.appendChild(document.createTextNode(' ')); + } + content.appendChild(copyableContent); + } + return {row, content}; + }) + .filter(({content}) => !!content.textContent); if (lines.length === 0) { return ''; diff --git a/tests/unit/SelectionScraperTest.ts b/tests/unit/SelectionScraperTest.ts index d77e1a728cda..7926d01ba9d1 100644 --- a/tests/unit/SelectionScraperTest.ts +++ b/tests/unit/SelectionScraperTest.ts @@ -84,6 +84,32 @@ describe('SelectionScraper', () => { expect(SelectionScraper.getCurrentSelection()).toBe('Aug 26 Paid
    Aug 27 Draft'); }); + it('preserves semantic formatting without layout wrappers in a multi-field selection', () => { + createFixture(` +
    + TJS 10.00 + +
    +
    + Test is + good +
    +
    +
    + Merchant +
    + `); + + selectText(getTextNode('amount'), 0, getTextNode('merchant'), 'Merchant'.length); + + const selectionHTML = SelectionScraper.getCurrentSelection(); + expect(selectionHTML).toBe('TJS 10.00 Test is good Merchant'); + expect(getClipboardText(selectionHTML)).toBe('TJS 10.00 Test is good Merchant'); + + selectText(getTextNode('formatted-description'), 'Test '.length, getTextNode('merchant'), 'Merchant'.length); + expect(SelectionScraper.getCurrentSelection()).toBe('is good Merchant'); + }); + it('joins a continuation row to the next row with a space', () => { // Given report fields followed by expense details in separate copyable rows createFixture(` From 34a360322b98f0bb7b51069fa7a49a37180c3e90 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 15:34:39 -0700 Subject: [PATCH 598/781] fix(support): wrap survey acknowledgement --- src/components/ReportActionItem/SupportTicketSurvey.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ReportActionItem/SupportTicketSurvey.tsx b/src/components/ReportActionItem/SupportTicketSurvey.tsx index 3105062b8fa5..4af75ac2c326 100644 --- a/src/components/ReportActionItem/SupportTicketSurvey.tsx +++ b/src/components/ReportActionItem/SupportTicketSurvey.tsx @@ -59,8 +59,8 @@ function SupportTicketSurvey({action, report, reportID}: SupportTicketSurveyProp } return ( - - {translate('supportTicket.surveyPrompt')} + + {translate('supportTicket.surveyPrompt')} {canRateSurvey && ( Date: Wed, 14 Oct 2026 01:38:29 +0200 Subject: [PATCH 599/781] Standardize selectable prop naming across copyable text components --- src/components/AvatarWithDisplayName.tsx | 14 +++++++------- .../DisplayNames/DisplayNamesTooltipItem.tsx | 10 +++++----- .../DisplayNames/DisplayNamesWithTooltip.tsx | 8 ++++---- .../DisplayNames/DisplayNamesWithoutTooltip.tsx | 10 +++++----- src/components/DisplayNames/index.native.tsx | 4 ++-- src/components/DisplayNames/index.tsx | 8 ++++---- src/components/DisplayNames/types.ts | 4 ++-- .../ReportActionItem/MoneyRequestView.tsx | 2 +- src/components/ReportSearchHeader/index.tsx | 6 +++--- src/components/ReportSearchHeader/types.ts | 4 ++-- .../SearchList/ListItem/AvatarWithTextCell.tsx | 10 +++++----- .../SearchList/ListItem/BaseListItemHeader.tsx | 4 ++-- .../SearchList/ListItem/CardListItemHeader.tsx | 8 ++++---- .../Search/SearchList/ListItem/DateCell.tsx | 2 +- .../SearchList/ListItem/MemberListItemHeader.tsx | 8 ++++---- .../SearchList/ListItem/ReportListItemHeader.tsx | 4 ++-- .../Search/SearchList/ListItem/TaskListItemRow.tsx | 8 ++++---- .../Search/SearchList/ListItem/TextCell.tsx | 2 +- .../SearchList/ListItem/TextWithIconCell.tsx | 2 +- .../Search/SearchList/ListItem/TotalCell.tsx | 2 +- .../ListItem/WithdrawalIDListItemHeader.tsx | 10 +++++----- .../Search/SearchList/ListItem/WorkspaceCell.tsx | 2 +- .../WorkspaceMembersTableRow.tsx | 8 ++++---- src/components/TextWithTooltip/index.tsx | 6 +++--- src/components/TextWithTooltip/types.ts | 4 ++-- .../TransactionItemRow/DataCells/CategoryCell.tsx | 2 +- .../TransactionItemRow/DataCells/MerchantCell.tsx | 2 +- .../TransactionItemRow/DataCells/TagCell.tsx | 2 +- .../TransactionItemRow/DataCells/TaxCell.tsx | 2 +- .../TransactionItemRow/DataCells/TotalCell.tsx | 2 +- .../TransactionItemRow/DataCells/TypeCell.tsx | 2 +- src/components/UserPill.tsx | 12 ++++++------ src/components/UserPills.tsx | 6 +++--- tests/ui/components/AvatarWithTextCellTest.tsx | 2 +- tests/ui/components/TaskListItemSelectionTest.tsx | 6 +++--- 35 files changed, 94 insertions(+), 94 deletions(-) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 063090cac215..3ec898ae2a38 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -89,8 +89,8 @@ type AvatarWithDisplayNameProps = { customDisplayNameStyle?: TextStyle; - /** Whether the displayed report title should be directly selectable/copyable */ - isDisplayNameCopyable?: boolean; + /** Whether the displayed report title should be directly selectable */ + isDisplayNameSelectable?: boolean; /** The style of the parent navigation subtitle text */ parentNavigationSubtitleTextStyles?: StyleProp; @@ -108,7 +108,7 @@ type CustomDisplayNameOptions = { regularStyle: TextStyle[]; isAnonymous: boolean; isMoneyRequestOrReport: boolean; - isCopyable: boolean; + isSelectable: boolean; }; function getCustomDisplayName({ @@ -122,7 +122,7 @@ function getCustomDisplayName({ regularStyle, isAnonymous, isMoneyRequestOrReport, - isCopyable, + isSelectable, }: CustomDisplayNameOptions): React.ReactNode { const reportName = report?.reportName ?? CONST.REPORT.DEFAULT_REPORT_NAME; const isIOUOrInvoice = report?.type === CONST.REPORT.TYPE.IOU || report?.type === CONST.REPORT.TYPE.INVOICE; @@ -184,7 +184,7 @@ function getCustomDisplayName({ numberOfLines={numberOfLines} textStyles={textStyles} shouldUseFullTitle={shouldUseFullTitleProp} - isCopyable={isCopyable} + isSelectable={isSelectable} /> ); } @@ -201,7 +201,7 @@ function AvatarWithDisplayName({ avatarBorderColor: avatarBorderColorProp, shouldDisplayStatus = false, customDisplayNameStyle = {}, - isDisplayNameCopyable = false, + isDisplayNameSelectable = false, parentNavigationSubtitleTextStyles, parentNavigationStatusContainerStyles = {}, }: AvatarWithDisplayNameProps) { @@ -305,7 +305,7 @@ function AvatarWithDisplayName({ regularStyle: [isAnonymous ? styles.headerAnonymousFooter : styles.headerText, styles.pre], isAnonymous, isMoneyRequestOrReport, - isCopyable: isDisplayNameCopyable, + isSelectable: isDisplayNameSelectable, }); const multipleAvatars = ( diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx index 2507415f08fc..12e99fdf4b84 100644 --- a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx +++ b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx @@ -36,8 +36,8 @@ type DisplayNamesTooltipItemProps = ForwardedFSClassProps & { /** Refs to all the names which will be used to correct the horizontal position of the tooltip */ childRefs: RefObject>>; - /** Whether this display name should be directly selectable/copyable inside pressable rows */ - isCopyable?: boolean; + /** Whether this display name should be directly selectable inside pressable rows */ + isSelectable?: boolean; }; function DisplayNamesTooltipItem({ @@ -50,7 +50,7 @@ function DisplayNamesTooltipItem({ textStyles = [], childRefs = {current: []}, forwardedFSClass, - isCopyable = false, + isSelectable = false, }: DisplayNamesTooltipItemProps) { const styles = useThemeStyles(); const tooltipIndexBridge = useCallback(() => getTooltipShiftX(index), [getTooltipShiftX, index]); @@ -77,8 +77,8 @@ function DisplayNamesTooltipItem({ }} style={[textStyles, styles.pre]} fsClass={forwardedFSClass} - selectable={isCopyable} - dataSet={isCopyable ? COPYABLE_TEXT_DATA_SET : undefined} + selectable={isSelectable} + dataSet={isSelectable ? COPYABLE_TEXT_DATA_SET : undefined} > {displayName} diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx index 9dcd47a50c60..274774ed863d 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -29,7 +29,7 @@ function DisplayNamesWithToolTip({ renderAdditionalText, forwardedFSClass, accessibilityLabel, - isCopyable = false, + isSelectable = false, }: DisplayNamesProps) { const styles = useThemeStyles(); const containerRef = useRef(null); @@ -75,8 +75,8 @@ function DisplayNamesWithToolTip({ ref={containerRef} testID="DisplayNamesWithToolTip" fsClass={forwardedFSClass} - selectable={isCopyable} - dataSet={isCopyable ? COPYABLE_TEXT_DATA_SET : undefined} + selectable={isSelectable} + dataSet={isSelectable ? COPYABLE_TEXT_DATA_SET : undefined} > {shouldUseFullTitle ? formatReportLastMessageText(fullTitle) @@ -93,7 +93,7 @@ function DisplayNamesWithToolTip({ textStyles={textStyles} childRefs={childRefs} forwardedFSClass={forwardedFSClass} - isCopyable={isCopyable} + isSelectable={isSelectable} /> {index < displayNamesWithTooltips.length - 1 && , } {shouldAddEllipsis && index === displayNamesWithTooltips.length - 1 && ...} diff --git a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx index dc92a77122bc..7dd8e0f7649e 100644 --- a/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithoutTooltip.tsx @@ -24,8 +24,8 @@ type DisplayNamesWithoutTooltipProps = ForwardedFSClassProps & { /** Overrides the text read by screen readers. */ accessibilityLabel?: string; - /** Whether this display name should be directly selectable/copyable inside pressable rows */ - isCopyable?: boolean; + /** Whether this display name should be directly selectable inside pressable rows */ + isSelectable?: boolean; }; function DisplayNamesWithoutTooltip({ @@ -35,7 +35,7 @@ function DisplayNamesWithoutTooltip({ renderAdditionalText, forwardedFSClass, accessibilityLabel, - isCopyable = false, + isSelectable = false, }: DisplayNamesWithoutTooltipProps) { const styles = useThemeStyles(); return ( @@ -44,8 +44,8 @@ function DisplayNamesWithoutTooltip({ style={[textStyles, numberOfLines === 1 ? styles.pre : styles.preWrap]} numberOfLines={numberOfLines} fsClass={forwardedFSClass} - selectable={isCopyable} - dataSet={isCopyable ? COPYABLE_TEXT_DATA_SET : undefined} + selectable={isSelectable} + dataSet={isSelectable ? COPYABLE_TEXT_DATA_SET : undefined} > {fullTitle} {renderAdditionalText?.()} diff --git a/src/components/DisplayNames/index.native.tsx b/src/components/DisplayNames/index.native.tsx index 5e57ecfd9fed..b07d4578867e 100644 --- a/src/components/DisplayNames/index.native.tsx +++ b/src/components/DisplayNames/index.native.tsx @@ -22,7 +22,7 @@ function DisplayNames({ forwardedFSClass, testID, shouldParseFullTitle = true, - isCopyable = false, + isSelectable = false, }: DisplayNamesProps) { const {translate} = useLocalize(); const titleContainsTextAndCustomEmoji = useMemo(() => containsCustomEmoji(fullTitle) && !containsOnlyCustomEmoji(fullTitle), [fullTitle]); @@ -38,7 +38,7 @@ function DisplayNames({ numberOfLines={numberOfLines} testID={`DisplayNames${testID !== undefined ? `-${testID}` : ''}`} fsClass={forwardedFSClass} - selectable={isCopyable} + selectable={isSelectable} > {titleContainsTextAndCustomEmoji ? ( { @@ -39,7 +39,7 @@ function DisplayNames({ renderAdditionalText={renderAdditionalText} forwardedFSClass={forwardedFSClass} accessibilityLabel={accessibilityLabel} - isCopyable={isCopyable} + isSelectable={isSelectable} /> ); } @@ -54,7 +54,7 @@ function DisplayNames({ renderAdditionalText={renderAdditionalText} forwardedFSClass={forwardedFSClass} accessibilityLabel={accessibilityLabel} - isCopyable={isCopyable} + isSelectable={isSelectable} /> ); } @@ -69,7 +69,7 @@ function DisplayNames({ renderAdditionalText={renderAdditionalText} forwardedFSClass={forwardedFSClass} accessibilityLabel={accessibilityLabel} - isCopyable={isCopyable} + isSelectable={isSelectable} /> ); } diff --git a/src/components/DisplayNames/types.ts b/src/components/DisplayNames/types.ts index ad54a2ecb0ea..89faf6d4b350 100644 --- a/src/components/DisplayNames/types.ts +++ b/src/components/DisplayNames/types.ts @@ -49,8 +49,8 @@ type DisplayNamesProps = ForwardedFSClassProps & { /** If we should add an ellipsis after the participants list */ shouldAddEllipsis?: boolean; - /** Whether this display name should be directly selectable/copyable inside pressable rows */ - isCopyable?: boolean; + /** Whether this display name should be directly selectable inside pressable rows */ + isSelectable?: boolean; /** Whether to parse HTML in the title */ shouldParseHtml?: boolean; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3ba808b4927e..fb30e81b2aba 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1691,7 +1691,7 @@ function MoneyRequestView({ email: a?.email, }))} maxVisible={canEdit ? undefined : actualAttendees.length} - isCopyable={areAttendeesCopyable} + isSelectable={areAttendeesCopyable} /> ) : undefined } diff --git a/src/components/ReportSearchHeader/index.tsx b/src/components/ReportSearchHeader/index.tsx index 8c5b83c439e8..ca6aada11ab3 100755 --- a/src/components/ReportSearchHeader/index.tsx +++ b/src/components/ReportSearchHeader/index.tsx @@ -10,7 +10,7 @@ import {View} from 'react-native'; import type ReportSearchHeaderProps from './types'; -function ReportSearchHeader({report, style, transactions, avatarBorderColor, isTitleCopyable = false}: ReportSearchHeaderProps) { +function ReportSearchHeader({report, style, transactions, avatarBorderColor, isTitleSelectable = false}: ReportSearchHeaderProps) { const styles = useThemeStyles(); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -28,7 +28,7 @@ function ReportSearchHeader({report, style, transactions, avatarBorderColor, isT shouldEnableDetailPageNavigation={false} shouldEnableAvatarNavigation={false} avatarBorderColor={avatarBorderColor} - isDisplayNameCopyable={isTitleCopyable} + isDisplayNameSelectable={isTitleSelectable} size={isLargeScreenWidth ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT} customDisplayNameStyle={styles.fontWeightNormal} parentNavigationSubtitleTextStyles={[styles.textLineHeightNormal, styles.minHeight4, styles.mt1, !isLargeScreenWidth && styles.textMicro]} @@ -39,7 +39,7 @@ function ReportSearchHeader({report, style, transactions, avatarBorderColor, isT report, transactions, avatarBorderColor, - isTitleCopyable, + isTitleSelectable, styles.fontWeightNormal, styles.textLineHeightNormal, styles.minHeight4, diff --git a/src/components/ReportSearchHeader/types.ts b/src/components/ReportSearchHeader/types.ts index e8aa9697289c..47a777b5d03f 100644 --- a/src/components/ReportSearchHeader/types.ts +++ b/src/components/ReportSearchHeader/types.ts @@ -14,8 +14,8 @@ type ReportSearchHeaderProps = { /** Color of the secondary avatar border, usually should match the container background */ avatarBorderColor?: ColorValue; - /** Whether the displayed report title should be directly selectable/copyable */ - isTitleCopyable?: boolean; + /** Whether the displayed report title should be directly selectable */ + isTitleSelectable?: boolean; }; export default ReportSearchHeaderProps; diff --git a/src/components/Search/SearchList/ListItem/AvatarWithTextCell.tsx b/src/components/Search/SearchList/ListItem/AvatarWithTextCell.tsx index 074e3f42ad7b..5dc42926e26c 100644 --- a/src/components/Search/SearchList/ListItem/AvatarWithTextCell.tsx +++ b/src/components/Search/SearchList/ListItem/AvatarWithTextCell.tsx @@ -20,10 +20,10 @@ type AvatarWithTextCellProps = { textStyle?: StyleProp; /** Whether the text can be selected inside a copyable row */ - isCopyable?: boolean; + isSelectable?: boolean; }; -function AvatarWithTextCell({reportName, icon, isLargeScreenWidth, textStyle, isCopyable = false}: AvatarWithTextCellProps) { +function AvatarWithTextCell({reportName, icon, isLargeScreenWidth, textStyle, isSelectable = false}: AvatarWithTextCellProps) { const styles = useThemeStyles(); if (!reportName || !icon) { @@ -40,7 +40,7 @@ function AvatarWithTextCell({reportName, icon, isLargeScreenWidth, textStyle, is return ( - {isCopyable ? ( + {isSelectable ? ( {reportName} diff --git a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx index b213f7d2d631..524194411de8 100644 --- a/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx @@ -121,7 +121,7 @@ function BaseListItemHeaderImpl({ @@ -168,7 +168,7 @@ function BaseListItemHeaderImpl({ diff --git a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx index 778ebfc21a5e..0df830787f92 100644 --- a/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/CardListItemHeader.tsx @@ -106,7 +106,7 @@ function CardListItemHeaderImpl({ text={cardItem.formattedCardName ?? ''} numberOfLines={2} style={[styles.preWrap]} - isCopyable + isSelectable />
    @@ -120,7 +120,7 @@ function CardListItemHeaderImpl({ text={cardItem.formattedFeedName ?? ''} numberOfLines={2} style={[styles.lineHeightLarge, styles.preWrap]} - isCopyable + isSelectable />
    ), @@ -171,13 +171,13 @@ function CardListItemHeaderImpl({
    diff --git a/src/components/Search/SearchList/ListItem/DateCell.tsx b/src/components/Search/SearchList/ListItem/DateCell.tsx index 1f0125976fe0..0e0d1046306d 100644 --- a/src/components/Search/SearchList/ListItem/DateCell.tsx +++ b/src/components/Search/SearchList/ListItem/DateCell.tsx @@ -57,7 +57,7 @@ function DateCell({date, showTooltip, isLargeScreenWidth, suffixText, shouldUseL text={displayText} shouldShowTooltip={showTooltip} style={[styles.lineHeightLarge, styles.pre, styles.justifyContentCenter, isLargeScreenWidth ? undefined : styles.mutedNormalTextLabel, !!suffixText && styles.flexShrink1]} - isCopyable + isSelectable /> ); diff --git a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx index d6567bc58192..c2906dce4d3d 100644 --- a/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/MemberListItemHeader.tsx @@ -97,12 +97,12 @@ function MemberListItemHeaderImpl({ @@ -156,12 +156,12 @@ function MemberListItemHeaderImpl({ diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index bdb8cbdc1725..820e1f56d584 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -175,8 +175,8 @@ function HeaderFirstRow({ style={[{maxWidth: 700}]} transactions={reportItem.transactions} avatarBorderColor={avatarBorderColor} - // Grouped report headers sit inside pressable rows, so their title needs the copyable marker too. - isTitleCopyable + // Grouped report headers sit inside pressable rows, so their title needs the selection marker too. + isTitleSelectable /> diff --git a/src/components/Search/SearchList/ListItem/TaskListItemRow.tsx b/src/components/Search/SearchList/ListItem/TaskListItemRow.tsx index db956e74ddba..92c4ef690c37 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItemRow.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItemRow.tsx @@ -63,7 +63,7 @@ function TitleCell({taskItem, showTooltip, isLargeScreenWidth}: TaskCellProps) { shouldShowTooltip={showTooltip} numberOfLines={2} style={[isLargeScreenWidth ? styles.lineHeightLarge : styles.lh20, styles.preWrap, styles.justifyContentCenter]} - isCopyable + isSelectable /> ); } @@ -77,7 +77,7 @@ function DescriptionCell({taskItem, showTooltip, isLargeScreenWidth}: TaskCellPr text={taskItem.description} numberOfLines={2} style={[styles.lineHeightLarge, styles.preWrap, styles.justifyContentCenter, isLargeScreenWidth ? undefined : [styles.textMicro, styles.textSupporting]]} - isCopyable + isSelectable /> ); } @@ -167,7 +167,7 @@ function TaskListItemRow({item, containerStyle, showTooltip}: TaskListItemRowPro reportName={item?.parentReportName} icon={item?.parentReportIcon} isLargeScreenWidth={isLargeScreenWidth} - isCopyable + isSelectable /> @@ -258,7 +258,7 @@ function TaskListItemRow({item, containerStyle, showTooltip}: TaskListItemRowPro reportName={item?.parentReportName} icon={item?.parentReportIcon} isLargeScreenWidth={isLargeScreenWidth} - isCopyable + isSelectable /> diff --git a/src/components/Search/SearchList/ListItem/TextCell.tsx b/src/components/Search/SearchList/ListItem/TextCell.tsx index 6c92f8c34ca1..f6a5d37bde6a 100644 --- a/src/components/Search/SearchList/ListItem/TextCell.tsx +++ b/src/components/Search/SearchList/ListItem/TextCell.tsx @@ -18,7 +18,7 @@ function TextCell({text = '', isLargeScreenWidth = true}: TextCellProps) { shouldShowTooltip numberOfLines={isLargeScreenWidth ? 1 : 2} style={[isLargeScreenWidth ? styles.lineHeightLarge : styles.lh20, !isLargeScreenWidth && styles.preWrap, styles.justifyContentCenter]} - isCopyable + isSelectable /> ); } diff --git a/src/components/Search/SearchList/ListItem/TextWithIconCell.tsx b/src/components/Search/SearchList/ListItem/TextWithIconCell.tsx index 7818a274752c..1c28df93eaca 100644 --- a/src/components/Search/SearchList/ListItem/TextWithIconCell.tsx +++ b/src/components/Search/SearchList/ListItem/TextWithIconCell.tsx @@ -41,7 +41,7 @@ export default function TextWithIconCell({icon, iconSize = 12, text, showTooltip shouldShowTooltip={showTooltip} numberOfLines={numberOfLines} style={[styles.label, numberOfLines > 1 ? styles.preWrap : styles.pre, styles.justifyContentCenter, styles.textMicro, styles.textSupporting, styles.flexShrink1, textStyle]} - isCopyable + isSelectable /> ); diff --git a/src/components/Search/SearchList/ListItem/TotalCell.tsx b/src/components/Search/SearchList/ListItem/TotalCell.tsx index 7b76e7cbe397..f1c49e841950 100644 --- a/src/components/Search/SearchList/ListItem/TotalCell.tsx +++ b/src/components/Search/SearchList/ListItem/TotalCell.tsx @@ -23,7 +23,7 @@ function TotalCell({total, currency, isScanning = false}: TotalCellProps) { shouldShowTooltip text={displayText} style={[styles.optionDisplayName, styles.pre, styles.justifyContentCenter, styles.textBold, styles.textAlignRight, styles.fontWeightNormal]} - isCopyable + isSelectable /> ); } diff --git a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx index 42e98381e8ad..6e30ed6b3020 100644 --- a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx @@ -134,7 +134,7 @@ function WithdrawalIDListItemHeaderImpl({ ), @@ -148,7 +148,7 @@ function WithdrawalIDListItemHeaderImpl({ ), @@ -168,7 +168,7 @@ function WithdrawalIDListItemHeaderImpl({ ), @@ -246,14 +246,14 @@ function WithdrawalIDListItemHeaderImpl({ {statusBadge} diff --git a/src/components/Search/SearchList/ListItem/WorkspaceCell.tsx b/src/components/Search/SearchList/ListItem/WorkspaceCell.tsx index 82b4982c8a94..2f0f347dccd2 100644 --- a/src/components/Search/SearchList/ListItem/WorkspaceCell.tsx +++ b/src/components/Search/SearchList/ListItem/WorkspaceCell.tsx @@ -44,7 +44,7 @@ function WorkspaceCell({policyID, report}: WorkspaceCellProps) { text={name ?? ''} shouldShowTooltip style={[styles.flex1, styles.flexGrow1]} - isCopyable + isSelectable /> ); diff --git a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx index 231af265aafb..bd2eff903e7f 100644 --- a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx +++ b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx @@ -96,14 +96,14 @@ export default function WorkspaceMembersTableRow({ text={item.name} style={[styles.optionDisplayName, styles.pre]} numberOfLines={1} - isCopyable + isSelectable /> @@ -119,7 +119,7 @@ export default function WorkspaceMembersTableRow({ numberOfLines={1} text={item.employeeUserID} style={[styles.lh16, styles.optionDisplayName, styles.pre]} - isCopyable + isSelectable /> )} @@ -136,7 +136,7 @@ export default function WorkspaceMembersTableRow({ numberOfLines={1} text={item.employeePayrollID} style={[styles.lh16, styles.optionDisplayName, styles.pre]} - isCopyable + isSelectable /> )} diff --git a/src/components/TextWithTooltip/index.tsx b/src/components/TextWithTooltip/index.tsx index c077d054649b..dee02f1f7d41 100644 --- a/src/components/TextWithTooltip/index.tsx +++ b/src/components/TextWithTooltip/index.tsx @@ -11,7 +11,7 @@ type LayoutChangeEvent = { target: HTMLElement; }; -function TextWithTooltip({testID, text, shouldShowTooltip, style, numberOfLines = 1, forwardedFSClass, isCopyable = false}: TextWithTooltipProps) { +function TextWithTooltip({testID, text, shouldShowTooltip, style, numberOfLines = 1, forwardedFSClass, isSelectable = false}: TextWithTooltipProps) { const [showTooltip, setShowTooltip] = useState(false); return ( @@ -23,7 +23,7 @@ function TextWithTooltip({testID, text, shouldShowTooltip, style, numberOfLines testID={testID} style={style} numberOfLines={numberOfLines} - selectable={isCopyable} + selectable={isSelectable} onLayout={(e) => { const target = (e.nativeEvent as unknown as LayoutChangeEvent).target; if (!shouldShowTooltip) { @@ -36,7 +36,7 @@ function TextWithTooltip({testID, text, shouldShowTooltip, style, numberOfLines setShowTooltip(false); }} fsClass={forwardedFSClass} - dataSet={isCopyable ? COPYABLE_TEXT_DATA_SET : undefined} + dataSet={isSelectable ? COPYABLE_TEXT_DATA_SET : undefined} > {text} diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts index ae57d3c1a40c..365b9bf6cbd5 100644 --- a/src/components/TextWithTooltip/types.ts +++ b/src/components/TextWithTooltip/types.ts @@ -12,8 +12,8 @@ type TextWithTooltipProps = ForwardedFSClassProps & { testID?: string; - /** Whether this value should be directly selectable/copyable inside pressable rows */ - isCopyable?: boolean; + /** Whether this value should be directly selectable inside pressable rows */ + isSelectable?: boolean; }; export default TextWithTooltipProps; diff --git a/src/components/TransactionItemRow/DataCells/CategoryCell.tsx b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx index b6de3fa697ba..5d1514dfbecb 100644 --- a/src/components/TransactionItemRow/DataCells/CategoryCell.tsx +++ b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx @@ -52,7 +52,7 @@ function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem text={categoryForDisplay} numberOfLines={1} style={[styles.lineHeightLarge, styles.justifyContentCenter]} - isCopyable + isSelectable /> ); diff --git a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx index 2b4a6900e8f0..e24ff67ef2b1 100644 --- a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx +++ b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx @@ -113,7 +113,7 @@ function MerchantOrDescriptionCell({merchantOrDescription, shouldShowTooltip, sh text={localValue} numberOfLines={1} style={shouldUseNarrowLayout ? [styles.lh20, styles.pre, styles.justifyContentCenter, styles.flex1] : [styles.lineHeightXLarge]} - isCopyable + isSelectable /> ); diff --git a/src/components/TransactionItemRow/DataCells/TagCell.tsx b/src/components/TransactionItemRow/DataCells/TagCell.tsx index 9e87bb6376ad..5e9f20b65823 100644 --- a/src/components/TransactionItemRow/DataCells/TagCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TagCell.tsx @@ -58,7 +58,7 @@ function TagCell({canEdit, onSave, shouldUseNarrowLayout, shouldShowTooltip, tra text={tagForDisplay} numberOfLines={1} style={[styles.lineHeightLarge, styles.justifyContentCenter]} - isCopyable + isSelectable /> ); diff --git a/src/components/TransactionItemRow/DataCells/TaxCell.tsx b/src/components/TransactionItemRow/DataCells/TaxCell.tsx index 037d785ffa47..2133500677b8 100644 --- a/src/components/TransactionItemRow/DataCells/TaxCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TaxCell.tsx @@ -21,7 +21,7 @@ function TaxCell({transactionItem, shouldShowTooltip}: TransactionDataCellProps) shouldShowTooltip={shouldShowTooltip} text={convertToDisplayString(taxAmount, currency)} style={[styles.optionDisplayName, styles.lineHeightLarge, styles.pre, styles.justifyContentCenter, styles.textAlignRight]} - isCopyable + isSelectable /> ); } diff --git a/src/components/TransactionItemRow/DataCells/TotalCell.tsx b/src/components/TransactionItemRow/DataCells/TotalCell.tsx index 261d2e96da0b..a123e6f23183 100644 --- a/src/components/TransactionItemRow/DataCells/TotalCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TotalCell.tsx @@ -147,7 +147,7 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report, shouldShowTooltip={shouldShowTooltip} text={amountToDisplay} style={[styles.optionDisplayName, styles.justifyContentCenter, styles.flexShrink1, styles.textAlignRight]} - isCopyable + isSelectable /> ); diff --git a/src/components/TransactionItemRow/DataCells/TypeCell.tsx b/src/components/TransactionItemRow/DataCells/TypeCell.tsx index 8007151a554d..f1162f227e21 100644 --- a/src/components/TransactionItemRow/DataCells/TypeCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TypeCell.tsx @@ -102,7 +102,7 @@ function TypeCell({transactionItem, shouldUseNarrowLayout, shouldShowTooltip}: T shouldShowTooltip={shouldShowTooltip} text={translate(typeText)} style={[styles.mutedNormalTextLabel, styles.pre, styles.justifyContentCenter, styles.flexShrink0]} - isCopyable + isSelectable /> ) : ( diff --git a/src/components/UserPill.tsx b/src/components/UserPill.tsx index e0042eac9639..df54fa5ebe63 100644 --- a/src/components/UserPill.tsx +++ b/src/components/UserPill.tsx @@ -23,10 +23,10 @@ type UserPillProps = { accountID?: number; email?: string; style?: StyleProp; - isCopyable?: boolean; + isSelectable?: boolean; }; -function UserPill({avatar, displayName, accountID, email, style, isCopyable = false}: UserPillProps) { +function UserPill({avatar, displayName, accountID, email, style, isSelectable = false}: UserPillProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {formatPhoneNumber} = useLocalize(); @@ -45,8 +45,8 @@ function UserPill({avatar, displayName, accountID, email, style, isCopyable = fa > {formattedDisplayName} diff --git a/src/components/UserPills.tsx b/src/components/UserPills.tsx index b0ab6074cc05..c3faa654e815 100644 --- a/src/components/UserPills.tsx +++ b/src/components/UserPills.tsx @@ -24,7 +24,7 @@ type UserPillData = { type UserPillsProps = { users: UserPillData[]; maxVisible?: number; - isCopyable?: boolean; + isSelectable?: boolean; } & ( | {onShowAllPress?: undefined; showAllSentryLabel?: undefined} | { @@ -37,7 +37,7 @@ type UserPillsProps = { const DEFAULT_MAX_VISIBLE = 6; -function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE, onShowAllPress, showAllSentryLabel, isCopyable = false}: UserPillsProps) { +function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE, onShowAllPress, showAllSentryLabel, isSelectable = false}: UserPillsProps) { const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); @@ -63,7 +63,7 @@ function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE, onShowAllPress, sho displayName={user.displayName} accountID={user.accountID} email={user.email} - isCopyable={isCopyable} + isSelectable={isSelectable} /> ); })} diff --git a/tests/ui/components/AvatarWithTextCellTest.tsx b/tests/ui/components/AvatarWithTextCellTest.tsx index d3bf429ce467..78fb289f379e 100644 --- a/tests/ui/components/AvatarWithTextCellTest.tsx +++ b/tests/ui/components/AvatarWithTextCellTest.tsx @@ -36,7 +36,7 @@ describe('AvatarWithTextCell', () => { reportName="Shared chat" icon={ICON} isLargeScreenWidth - isCopyable + isSelectable />, ); diff --git a/tests/ui/components/TaskListItemSelectionTest.tsx b/tests/ui/components/TaskListItemSelectionTest.tsx index 19a591c0e86a..82849277d5f9 100644 --- a/tests/ui/components/TaskListItemSelectionTest.tsx +++ b/tests/ui/components/TaskListItemSelectionTest.tsx @@ -88,9 +88,9 @@ describe('TaskListItem text selection', () => { // When the row is rendered, then its press handling allows native selection for each copyable value. expect(mockedListItemComposed).toHaveBeenCalledWith(expect.objectContaining({shouldAllowTextSelection: true}), undefined); - expect(mockedTextWithTooltip).toHaveBeenCalledWith(expect.objectContaining({text: 'Review expense', isCopyable: true}), undefined); - expect(mockedTextWithTooltip).toHaveBeenCalledWith(expect.objectContaining({text: 'Review the expense', isCopyable: true}), undefined); - expect(mockedAvatarWithTextCell).toHaveBeenCalledWith(expect.objectContaining({reportName: 'Shared chat', isCopyable: true}), undefined); + expect(mockedTextWithTooltip).toHaveBeenCalledWith(expect.objectContaining({text: 'Review expense', isSelectable: true}), undefined); + expect(mockedTextWithTooltip).toHaveBeenCalledWith(expect.objectContaining({text: 'Review the expense', isSelectable: true}), undefined); + expect(mockedAvatarWithTextCell).toHaveBeenCalledWith(expect.objectContaining({reportName: 'Shared chat', isSelectable: true}), undefined); }); it('excludes the compact assignee avatar from copied content', () => { From a999a407640a66ce6e3c11b6a7cca72f55ff23bb Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 13 Oct 2026 19:41:09 -0400 Subject: [PATCH 600/781] Request 6h Rock cache sessions Matches the role's maximum and the default 6h job timeout. --- .github/workflows/buildAndroid.yml | 2 +- .github/workflows/buildIOS.yml | 2 +- .github/workflows/remote-build-android.yml | 2 +- .github/workflows/remote-build-ios.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index 86c960c67b3d..6a50c5be23b5 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -158,7 +158,7 @@ jobs: with: role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} - role-duration-seconds: 10800 + role-duration-seconds: 21600 aws-region: us-east-1 - name: Rock Remote Build - Android diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index 4276b2020ffb..327fe6a75434 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -239,7 +239,7 @@ jobs: with: role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} - role-duration-seconds: 10800 + role-duration-seconds: 21600 aws-region: us-east-1 - name: Prepare provisioning profiles JSON diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml index d48e77add8fa..34af4119761e 100644 --- a/.github/workflows/remote-build-android.yml +++ b/.github/workflows/remote-build-android.yml @@ -85,7 +85,7 @@ jobs: with: role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} - role-duration-seconds: 10800 + role-duration-seconds: 21600 aws-region: us-east-1 - name: Rock Remote Build - Android diff --git a/.github/workflows/remote-build-ios.yml b/.github/workflows/remote-build-ios.yml index 32083380cc47..1bd31521e7d8 100644 --- a/.github/workflows/remote-build-ios.yml +++ b/.github/workflows/remote-build-ios.yml @@ -97,7 +97,7 @@ jobs: with: role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} - role-duration-seconds: 10800 + role-duration-seconds: 21600 aws-region: us-east-1 - name: Rock Remote Build - iOS From a88db7bea985cba42f0a93c003009f4cba469939 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 16:46:30 -0700 Subject: [PATCH 601/781] fix(support): preserve concierge feedback behavior --- .../inbox/report/actionContents/ConciergeFeedbackPrompt.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index c65269ae59c5..52f08a093c82 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -140,10 +140,12 @@ function ConciergeFeedbackPrompt({ // Either rating starts the acknowledgement window, and the zero fallback keeps an unanswered survey undefined. const latestReactionTimestamp = Math.max(thumbsUpReactedAt ?? 0, thumbsDownReactedAt ?? 0) || undefined; + // Regular Concierge feedback keeps its existing thumbs-up-only acknowledgement, while surveys acknowledge either rating. + const acknowledgementTimestamp = shouldPersistAfterRating ? latestReactionTimestamp : thumbsUpReactedAt; // The reaction that was already there when this row mounted is an old rating, so only a reaction that lands while the row is open is acknowledged - const [mountedWithLatestReactionTimestamp] = useState(latestReactionTimestamp); - const hasJustReacted = latestReactionTimestamp !== undefined && latestReactionTimestamp !== mountedWithLatestReactionTimestamp; + const [mountedWithAcknowledgementTimestamp] = useState(acknowledgementTimestamp); + const hasJustReacted = acknowledgementTimestamp !== undefined && acknowledgementTimestamp !== mountedWithAcknowledgementTimestamp; // The debounced copy catches up once the window has passed, which is what takes the acknowledgement back down const hasSettledAfterReacting = useDebouncedValue(hasJustReacted, THANKS_VISIBLE_DURATION_MS); From 3473126d9ac51e25b65d57b44e4efe3f01c1fd75 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 16:58:59 -0700 Subject: [PATCH 602/781] fix(support): keep survey rating options visible --- .../report/actionContents/ConciergeFeedbackPrompt.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index 52f08a093c82..ee39ff97a883 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -176,9 +176,6 @@ function ConciergeFeedbackPrompt({ ); - const selectedEmoji = thumbsUpReactedAt ? thumbsUp : thumbsDown; - const selectedFeedbackThumb = getFeedbackThumb(selectedEmoji, true); - // The acknowledgement comes from the reaction, so removing it from the reaction row brings the prompt back right away if (!shouldPersistAfterRating && isDisplayedThankMessage) { return {translate('concierge.feedback.thanks')}; @@ -190,9 +187,9 @@ function ConciergeFeedbackPrompt({ if (shouldPersistAfterRating && hasRated) { return ( - + {isDisplayedThankMessage && {translate('concierge.feedback.thanks')}} - {selectedFeedbackThumb} + {feedbackThumbs} ); } From 163708efad1838c764647e6380dddce5e25611b6 Mon Sep 17 00:00:00 2001 From: X Developer Date: Wed, 14 Oct 2026 02:03:06 +0200 Subject: [PATCH 603/781] Limit DotIndicatorMessage selection override to web --- src/components/DotIndicatorMessage.tsx | 10 ++- .../ui/components/DotIndicatorMessageTest.tsx | 85 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/ui/components/DotIndicatorMessageTest.tsx diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 51f2946c6611..eb4a2bec6e91 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {isReceiptError, isTranslationKeyError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; +import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; @@ -76,9 +77,12 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, isSelectab const isErrorMessage = type === 'error'; const receiptError = uniqueMessages.find(isReceiptError); - const isTextSelectable = isSelectable ?? (!canUseTouchScreen() || !shouldUseNarrowLayout); + const isWeb = getPlatform() === CONST.PLATFORM.WEB; + const defaultTextSelectable = !canUseTouchScreen() || !shouldUseNarrowLayout; + const isTextSelectable = isWeb ? (isSelectable ?? defaultTextSelectable) : defaultTextSelectable; + const shouldDisableTextSelection = isWeb && isSelectable === false; const selectionDataSet = isTextSelectable ? undefined : {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}; - const receiptSelectionDataSet = isSelectable === false ? selectionDataSet : undefined; + const receiptSelectionDataSet = shouldDisableTextSelection ? selectionDataSet : undefined; const renderMessage = (message: string | ReceiptError | ReactElement, index: number) => { if (isReceiptError(message)) { @@ -129,7 +133,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, isSelectab const messageRow = ( diff --git a/tests/ui/components/DotIndicatorMessageTest.tsx b/tests/ui/components/DotIndicatorMessageTest.tsx new file mode 100644 index 000000000000..cf35f99881a9 --- /dev/null +++ b/tests/ui/components/DotIndicatorMessageTest.tsx @@ -0,0 +1,85 @@ +import {render, screen} from '@testing-library/react-native'; + +import DotIndicatorMessage from '@components/DotIndicatorMessage'; + +import type * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +let mockShouldUseNarrowLayout = false; + +jest.mock('@components/Icon', () => jest.fn(() => null)); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({DotIndicator: () => null})), +})); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useResponsiveLayout', () => + jest.fn(() => ({ + shouldUseNarrowLayout: mockShouldUseNarrowLayout, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + })), +); +jest.mock('@hooks/useStyleUtils', () => jest.fn(() => ({getDotIndicatorTextStyles: jest.fn(() => ({}))}))); +jest.mock('@hooks/useTheme', () => jest.fn(() => ({danger: '', success: '', text: ''}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + userSelectText: {userSelect: 'text'}, + userSelectNone: {userSelect: 'none'}, + dotIndicatorMessage: {}, + offlineFeedbackErrorDot: {}, + offlineFeedbackTextContainer: {}, + })), +); +jest.mock('@libs/DeviceCapabilities', () => ({ + ...jest.requireActual('@libs/DeviceCapabilities'), + canUseTouchScreen: jest.fn(), +})); +jest.mock('@libs/getPlatform', () => jest.fn()); + +const mockedCanUseTouchScreen = jest.mocked(canUseTouchScreen); +const mockedGetPlatform = jest.mocked(getPlatform); +const MESSAGE = 'Company card required'; + +function renderMessage() { + return render( + , + ); +} + +describe('DotIndicatorMessage selection override', () => { + beforeEach(() => { + mockShouldUseNarrowLayout = false; + mockedCanUseTouchScreen.mockReturnValue(false); + }); + + it('disables text selection on web when explicitly requested', () => { + // Given a web layout whose default behavior allows text selection. + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + + // When the caller explicitly disables selection. + renderMessage(); + + // Then the web-specific override prevents text selection. + expect(screen.getByText(MESSAGE)).toHaveStyle({userSelect: 'none'}); + }); + + it('preserves the default text selection behavior on native', () => { + // Given a native layout whose default behavior allows text selection. + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.IOS); + + // When the caller passes the web-specific selection override. + renderMessage(); + + // Then native ignores the override and keeps its existing selection behavior. + expect(screen.getByText(MESSAGE)).toHaveStyle({userSelect: 'text'}); + }); +}); From 694df076ea8c5d7eac11659051b13da044c4d5e2 Mon Sep 17 00:00:00 2001 From: Linh Date: Wed, 14 Oct 2026 07:52:08 +0700 Subject: [PATCH 604/781] Drop unreachable fallback for required transactionsOfDeletableReportCollection --- src/libs/actions/MergeTransaction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index a53bf0cc059f..e76d42f4e2a9 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -555,7 +555,7 @@ function mergeTransactionRequest({ }; const transactionToDelete = mergeTransaction.reportID === targetTransaction.reportID ? sourceTransaction : targetTransaction; - const transactionsOfDeletableReport = Object.values(transactionsOfDeletableReportCollection ?? {}); + const transactionsOfDeletableReport = Object.values(transactionsOfDeletableReportCollection); const deletableReport = getReportOrDraftReport(transactionToDelete.reportID); const reportPreviewActionIDOfDeletableReport = deletableReport?.parentReportActionID; const optimisticReportDeletionData: Array> = From 22b3b8c1242929e99bad9cfddca33bbe18815141 Mon Sep 17 00:00:00 2001 From: X Developer Date: Wed, 14 Oct 2026 03:11:15 +0200 Subject: [PATCH 605/781] Fix deprecated MenuItem lint errors in selection tests --- .../components/MenuItem/MenuItemCopyTest.tsx | 31 ++++++++++--------- tests/ui/components/MenuItemTest.tsx | 28 ++++++++--------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/tests/ui/components/MenuItem/MenuItemCopyTest.tsx b/tests/ui/components/MenuItem/MenuItemCopyTest.tsx index d8ec55fd11fd..957901c93c35 100644 --- a/tests/ui/components/MenuItem/MenuItemCopyTest.tsx +++ b/tests/ui/components/MenuItem/MenuItemCopyTest.tsx @@ -102,22 +102,23 @@ function Row({isValueSelectable = false}: {isValueSelectable?: boolean}) { } function LegacyRow({isTitleSelectable = false}: {isTitleSelectable?: boolean}) { - return ( - - - + const legacyMenuItem = ( + // eslint-disable-next-line @typescript-eslint/no-deprecated -- This verifies backward-compatible selection behavior in the legacy MenuItem implementation. + ); + + return {legacyMenuItem}; } function mockCopyableTextHitTest(isPressStartOnCopyableText: (event: unknown) => boolean) { diff --git a/tests/ui/components/MenuItemTest.tsx b/tests/ui/components/MenuItemTest.tsx index b3343d88ecae..22eeefce6652 100644 --- a/tests/ui/components/MenuItemTest.tsx +++ b/tests/ui/components/MenuItemTest.tsx @@ -41,6 +41,17 @@ function Wrapper({children}: {children: React.ReactNode}) { return {children}; } +function SelectableHTMLMenuItem({isTitleSelectable = false}: {isTitleSelectable?: boolean}) { + return ( + // eslint-disable-next-line @typescript-eslint/no-deprecated -- This test file verifies behavior that remains supported by the legacy MenuItem implementation. + + ); +} + describe('MenuItem', () => { beforeEach(() => { mockRenderHTML.mockClear(); @@ -53,11 +64,7 @@ describe('MenuItem', () => { render( - + , ); @@ -69,10 +76,7 @@ describe('MenuItem', () => { render( - + , ); @@ -82,11 +86,7 @@ describe('MenuItem', () => { it('does not override HTML title selection on native', () => { render( - + , ); From 8ac5bc239b648df3e194e568c1bcd24b65677e32 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 18:15:08 -0700 Subject: [PATCH 606/781] fix(support): restart survey acknowledgement timer --- .../report/actionContents/ConciergeFeedbackPrompt.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index ee39ff97a883..ffa5b4e2724a 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -145,11 +145,11 @@ function ConciergeFeedbackPrompt({ // The reaction that was already there when this row mounted is an old rating, so only a reaction that lands while the row is open is acknowledged const [mountedWithAcknowledgementTimestamp] = useState(acknowledgementTimestamp); - const hasJustReacted = acknowledgementTimestamp !== undefined && acknowledgementTimestamp !== mountedWithAcknowledgementTimestamp; - // The debounced copy catches up once the window has passed, which is what takes the acknowledgement back down - const hasSettledAfterReacting = useDebouncedValue(hasJustReacted, THANKS_VISIBLE_DURATION_MS); - const isDisplayedThankMessage = hasJustReacted && !hasSettledAfterReacting; + // The debounced timestamp catches up once the window has passed, and a new reaction restarts that window. + const debouncedAcknowledgementTimestamp = useDebouncedValue(acknowledgementTimestamp, THANKS_VISIBLE_DURATION_MS); + const isDisplayedThankMessage = + acknowledgementTimestamp !== undefined && acknowledgementTimestamp !== mountedWithAcknowledgementTimestamp && acknowledgementTimestamp !== debouncedAcknowledgementTimestamp; const rate = (emoji: Emoji) => { // Skin tone is ignored on compare so a user whose preferred tone changed toggles their existing reaction instead of adding a second one From 5559922d186185ff6c90bfa060650881912346cf Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 18:20:04 -0700 Subject: [PATCH 607/781] docs(app): clarify survey test wording --- tests/unit/OnyxDerived/visibleReportActionsTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/OnyxDerived/visibleReportActionsTest.ts b/tests/unit/OnyxDerived/visibleReportActionsTest.ts index 8af546c90fef..86af1538fc7d 100644 --- a/tests/unit/OnyxDerived/visibleReportActionsTest.ts +++ b/tests/unit/OnyxDerived/visibleReportActionsTest.ts @@ -34,7 +34,7 @@ describe('visibleReportActions', () => { }); it('hides a support survey when the ticket has been replaced', () => { - // Given a cached support survey that was visible before the replacement ticket arrived + // Given a support survey that was visible before the replacement ticket arrived const reportID = 'supportTicketReportID'; const surveyActionID = 'surveyActionID'; const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; @@ -69,7 +69,7 @@ describe('visibleReportActions', () => { triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]), }); - // Then the shared visibility cache hides the stale survey + // Then the shared visibility state hides the stale survey expect(result[reportID]?.[surveyActionID]).toBe(false); expect(result[unrelatedReportID]?.[unrelatedActionID]).toBe(true); }); From 014eafbea646f3f5f9a41cdf81a0e817a42cae54 Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Wed, 14 Oct 2026 02:17:08 +0000 Subject: [PATCH 608/781] Let ArrowUp and pressing the search input clear the focused row on the workspace invite list ArrowUp from the first row now moves to the search input instead of wrapping to the last row, and pressing the search input clears the highlighted row. With no highlighted row, Enter in the search input goes to the Next button instead of toggling a member. Co-authored-by: huult --- .../BaseSelectionListWithSections.tsx | 3 ++ .../SelectionListWithSections/types.ts | 6 +++ .../SelectionList/components/TextInput.tsx | 5 ++ .../hooks/useSelectionListKeyboardFocus.ts | 7 +++ .../workspace/DynamicWorkspaceInvitePage.tsx | 1 + .../SelectionListEnterConfirmGateTest.tsx | 51 +++++++++++++++++++ tests/unit/useArrowKeyFocusManagerTest.ts | 35 +++++++++++++ 7 files changed, 108 insertions(+) diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 5d360090ab17..6c993d361991 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -87,6 +87,7 @@ function BaseSelectionListWithSectionsImpl({ shouldSingleExecuteRowSelect = false, shouldPreventDefaultFocusOnSelectRow = false, shouldPreventAutoScrollOnSelect = false, + shouldAllowArrowUpToSearchInput = false, titleNumberOfLines, shouldDisableHoverStyle, selectionButtonPosition, @@ -123,6 +124,7 @@ function BaseSelectionListWithSectionsImpl({ debouncedScrollToIndex, announceProgrammaticScroll: () => listRef.current?.announceProgrammaticScroll(), setShouldDisableHoverStyle, + shouldAllowArrowUpToSearchInput, }); const {innerTextInputRef, isTextInputFocusedRef, focusTextInput, textInputKeyPress} = useSelectionListTextInput(setHasKeyBeenPressed); @@ -268,6 +270,7 @@ function BaseSelectionListWithSectionsImpl({ accessibilityLabel={textInputOptions?.label} options={textInputOptions} onSubmit={selectFocusedItem} + onPress={shouldAllowArrowUpToSearchInput ? () => setFocusedIndex(-1) : undefined} dataLength={itemsCount} isLoading={isLoadingNewOptions} onFocusChange={(v: boolean) => (isTextInputFocusedRef.current = v)} diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index f89012a01848..f522382b5f47 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -39,6 +39,12 @@ type SelectionListWithSectionsProps = BaseSelectionListP /** Whether to prevent auto-scrolling to the first index when selecting an item in multi-select mode */ shouldPreventAutoScrollOnSelect?: boolean; + /** + * Whether ArrowUp from the first row, or pressing the search input, clears the focused row so focus returns to the search input. + * With no focused row, plain Enter in the search input reaches the confirm button instead of toggling a row. + */ + shouldAllowArrowUpToSearchInput?: boolean; + /** * Passed straight to the underlying list. Defaults to `always`, which a list whose own text input drives its * rows needs: a row press has to land while that input still holds focus. A list whose rows are inputs of their diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 39db41b44844..d6cf0864a713 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -38,6 +38,9 @@ type TextInputProps = { /** Callback function called when the text input is submitted */ onSubmit?: () => void; + /** Callback function called when the text input is pressed */ + onPress?: () => void; + onKeyPress?: (event: TextInputKeyPressEvent) => void; /** Function called when the text input focus changes */ @@ -59,6 +62,7 @@ function TextInput({ isLoading = false, dataLength, onSubmit, + onPress, onKeyPress, onFocusChange, shouldShowLoadingPlaceholder, @@ -170,6 +174,7 @@ function TextInput({ selectTextOnFocus spellCheck={false} onSubmitEditing={onSubmit} + onPress={onPress} submitBehavior={dataLength ? 'blurAndSubmit' : 'submit'} isLoading={isLoading} shouldHideClearButton={false} diff --git a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts index 176ed6162cba..4212d475e512 100644 --- a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts +++ b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts @@ -20,6 +20,9 @@ type UseSelectionListKeyboardFocusParams = { debouncedScrollToIndex: ScrollToIndex; announceProgrammaticScroll: () => void; setShouldDisableHoverStyle: (shouldDisableHoverStyle: boolean) => void; + + /** Whether ArrowUp from the first row moves to index -1 (the search input) instead of wrapping to the last row */ + shouldAllowArrowUpToSearchInput?: boolean; }; type UseSelectionListKeyboardFocusResult = { @@ -42,6 +45,7 @@ function useSelectionListKeyboardFocus({ debouncedScrollToIndex, announceProgrammaticScroll, setShouldDisableHoverStyle, + shouldAllowArrowUpToSearchInput = false, }: UseSelectionListKeyboardFocusParams): UseSelectionListKeyboardFocusResult { const hasKeyBeenPressed = useRef(false); const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false); @@ -80,6 +84,9 @@ function useSelectionListKeyboardFocus({ }, setHasKeyBeenPressed, isFocused, + // Negative indexes let ArrowUp break out past a disabled section header at index 0 + disableCyclicTraversal: shouldAllowArrowUpToSearchInput, + allowNegativeIndexes: shouldAllowArrowUpToSearchInput, onArrowUpDownCallback: () => { setShouldDisableHoverStyle(true); announceProgrammaticScroll(); diff --git a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx index 2d04d942b0ac..bfd1449dc5c3 100644 --- a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx @@ -278,6 +278,7 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun textInputOptions={textInputOptions} shouldUpdateFocusedIndex shouldPreventAutoScrollOnSelect + shouldAllowArrowUpToSearchInput confirmButtonOptions={{ onConfirm: inviteUser, isDisabled: !selectedOptions.length, diff --git a/tests/unit/SelectionListEnterConfirmGateTest.tsx b/tests/unit/SelectionListEnterConfirmGateTest.tsx index d96521425426..b5954dbb0a3b 100644 --- a/tests/unit/SelectionListEnterConfirmGateTest.tsx +++ b/tests/unit/SelectionListEnterConfirmGateTest.tsx @@ -262,3 +262,54 @@ describe.each([ expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); }); }); + +describe('BaseSelectionListWithSections search input press', () => { + beforeEach(() => { + mockUseSelectionListShortcuts.mockClear(); + jest.mocked(NativeNavigation.useIsFocused).mockReturnValue(true); + }); + + function renderInviteLikeList(shouldAllowArrowUpToSearchInput: boolean) { + return render( + + + , + ); + } + + it('clears the focused row when the search input is pressed, so plain Enter reaches the confirm button again', () => { + // Given the invite list after keyboard navigation, where Enter would toggle the focused row + renderInviteLikeList(true); + fireEvent(screen.getByTestId('selection-list-text-input'), 'keyPress', {nativeEvent: {key: 'Tab'}}); + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + + // When the user presses the search input with the mouse + fireEvent.press(screen.getByTestId('selection-list-text-input')); + + // Then no row is focused, so Enter in the search input goes to the confirm button + expect(getGatedFocusedIndex()).toBe(-1); + }); + + it('keeps the focused row when the search input is pressed and the list does not opt in', () => { + // Given a list that has not opted in, after keyboard navigation + renderInviteLikeList(false); + fireEvent(screen.getByTestId('selection-list-text-input'), 'keyPress', {nativeEvent: {key: 'Tab'}}); + + // When the user presses the search input + fireEvent.press(screen.getByTestId('selection-list-text-input')); + + // Then the focused row still owns Enter, preserving existing behavior for other lists + expect(getGatedFocusedIndex()).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tests/unit/useArrowKeyFocusManagerTest.ts b/tests/unit/useArrowKeyFocusManagerTest.ts index ecd2d989a32f..c4e75c13a550 100644 --- a/tests/unit/useArrowKeyFocusManagerTest.ts +++ b/tests/unit/useArrowKeyFocusManagerTest.ts @@ -20,6 +20,15 @@ function pressArrowDown() { }); } +function pressArrowUp() { + act(() => { + if (!shortcuts.ArrowUp?.isActive) { + return; + } + shortcuts.ArrowUp.callback(); + }); +} + describe('useArrowKeyFocusManager', () => { afterEach(() => { for (const key of Object.keys(shortcuts)) { @@ -174,4 +183,30 @@ describe('useArrowKeyFocusManager', () => { expect(onFocusedIndexChange).toHaveBeenCalledTimes(1); }); + + it('moves ArrowUp past a disabled header at index 0 to -1 when cyclic traversal is off and negative indexes are allowed', () => { + // Given a sectioned list (header at index 0) that lets ArrowUp leave the list for the search input + const {result} = renderHook(() => + useArrowKeyFocusManager({ + maxIndex: 3, + initialFocusedIndex: 1, + disabledIndexes: [0], + disableCyclicTraversal: true, + allowNegativeIndexes: true, + isActive: true, + }), + ); + + // When ArrowUp is pressed on the first row + pressArrowUp(); + + // Then focus leaves the rows instead of wrapping to the last row + expect(result.current[0]).toBe(-1); + + // When ArrowDown is pressed from the search input + pressArrowDown(); + + // Then focus returns to the first row, skipping the header + expect(result.current[0]).toBe(1); + }); }); From 9268036cdcdfe40e8841828d3c3ffbd16e0dfc6f Mon Sep 17 00:00:00 2001 From: allgandaf Date: Wed, 14 Oct 2026 08:50:16 +0530 Subject: [PATCH 609/781] let the share code route show only the QR code --- src/ROUTES.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ec209e089488..8dba378b951d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1583,6 +1583,8 @@ const DYNAMIC_ROUTES = { SCREENS.SEARCH.ROOT, SCREENS.REPORT_DETAILS.DYNAMIC_ROOT, ], + getRoute: (shouldShowQRCodeOnly?: boolean) => getUrlWithParams('share-code', {shouldShowQRCodeOnly: shouldShowQRCodeOnly ? 'true' : undefined}), + queryParams: ['shouldShowQRCodeOnly'], }, REFERRAL_DETAILS: { path: 'referral/:contentType', From 2f31ee074b7867161b69636ffa7677df92fe07e1 Mon Sep 17 00:00:00 2001 From: allgandaf Date: Wed, 14 Oct 2026 08:50:19 +0530 Subject: [PATCH 610/781] add shouldShowQRCodeOnly to the share code params --- src/libs/Navigation/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 687fb00d9697..dc907a06eb84 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2285,6 +2285,7 @@ type ReportDetailsNavigatorParamList = { }; [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: { reportID: string; + shouldShowQRCodeOnly?: string; }; [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: { reportID: string; From a6f7e36f6f53ccac8ea4b6445137a37448a628dd Mon Sep 17 00:00:00 2001 From: allgandaf Date: Wed, 14 Oct 2026 08:50:20 +0530 Subject: [PATCH 611/781] hide the rows under the QR code when asked --- src/pages/ShareCodePage.tsx | 75 ++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index da1ab57c8f81..5e7fb738b008 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -58,6 +58,9 @@ type ShareCodePageOnyxProps = { /** The policy for the report currently being looked at */ policy?: OnyxEntry; + + /** Whether to show only the QR code, without the rows under it */ + shouldShowQRCodeOnly?: boolean; }; type ShareCodePageProps = ShareCodePageOnyxProps & BackToParams; @@ -98,7 +101,7 @@ function createAvatarQRLogo(LocalAvatarLogo: React.FC): React.FC - - Clipboard.setString(url)} - shouldLimitWidth={false} - /> - {/* Remove this platform specific condition once https://github.com/Expensify/App/issues/19834 is done. - We shouldn't introduce platform specific code in our codebase. - This is a temporary solution while Web is not supported for the QR code download feature */} - {shouldAllowDownloadQRCode && ( - qrCodeRef.current?.download?.()}> - - - - - - {translate('common.download')} - - - - )} - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))); - }} - /> - + {!shouldShowQRCodeOnly && ( + + Clipboard.setString(url)} + shouldLimitWidth={false} + /> + {/* Remove this platform specific condition once https://github.com/Expensify/App/issues/19834 is done. + We shouldn't introduce platform specific code in our codebase. + This is a temporary solution while Web is not supported for the QR code download feature */} + {shouldAllowDownloadQRCode && ( + qrCodeRef.current?.download?.()}> + + + + + + {translate('common.download')} + + + + )} + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))); + }} + /> + + )} ); From 7a3a1a38dbbf55ae4b9d71753bc0f53b83e891cd Mon Sep 17 00:00:00 2001 From: allgandaf Date: Wed, 14 Oct 2026 08:50:36 +0530 Subject: [PATCH 612/781] pass shouldShowQRCodeOnly to the share code page --- src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx b/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx index 11c5d7b12c95..f77da2477a38 100644 --- a/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx +++ b/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx @@ -18,7 +18,7 @@ import withReportOrNotFound from './withReportOrNotFound'; type DynamicReportDetailsShareCodePageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; -function DynamicReportDetailsShareCodePage({report, policy}: DynamicReportDetailsShareCodePageProps) { +function DynamicReportDetailsShareCodePage({report, policy, route}: DynamicReportDetailsShareCodePageProps) { const navigateBackRoute = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.path); if (isSelfDM(report)) { @@ -30,6 +30,7 @@ function DynamicReportDetailsShareCodePage({report, policy}: DynamicReportDetail backTo={navigateBackRoute} report={report} policy={policy} + shouldShowQRCodeOnly={route.params?.shouldShowQRCodeOnly === 'true'} /> ); } From e8abfb6f4dbac81a867fe4a9bea09ad317059d10 Mon Sep 17 00:00:00 2001 From: allgandaf Date: Wed, 14 Oct 2026 08:50:37 +0530 Subject: [PATCH 613/781] open share code with only the QR code from the share menu --- src/hooks/useShareActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useShareActions.ts b/src/hooks/useShareActions.ts index d6ba794730ca..1781f7c85b8f 100644 --- a/src/hooks/useShareActions.ts +++ b/src/hooks/useShareActions.ts @@ -45,7 +45,7 @@ function useShareActions({reportID, policy}: UseShareActionsParams): UseShareAct text: translate('common.shareCode'), icon: expensifyIcons.QrCode, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE_CODE, - onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.path)), + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.getRoute(true))), }, { text: translate(`referralProgram.${CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND}.header`), From 41198bfd1827f90f7a45a366ad0ce9276acc5291 Mon Sep 17 00:00:00 2001 From: allgandaf Date: Wed, 14 Oct 2026 08:51:09 +0530 Subject: [PATCH 614/781] test share code opens with only the QR code --- tests/unit/hooks/useShareActionsTest.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/hooks/useShareActionsTest.ts b/tests/unit/hooks/useShareActionsTest.ts index 2e6fe9647776..d92ded15706d 100644 --- a/tests/unit/hooks/useShareActionsTest.ts +++ b/tests/unit/hooks/useShareActionsTest.ts @@ -119,6 +119,17 @@ describe('useShareActions', () => { expect(Navigation.navigate).toHaveBeenCalledWith(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path); }); + it('opens share code with only the QR code', () => { + // Given the share options of a report + const options = getShareOptions(); + + // When share code is selected + options.at(1)?.onSelected?.(); + + // Then the share code page opens without the copy and referral rows the share menu already has + expect(Navigation.navigate).toHaveBeenCalledWith('share-code?shouldShowQRCodeOnly=true'); + }); + it('copies the report URL', () => { // Given the share options of a report const options = getShareOptions(); From 125ca02caed859fca7ddb4691e2fb5d0dfc21efe Mon Sep 17 00:00:00 2001 From: Phuoc Hiep Date: Wed, 14 Oct 2026 10:21:21 +0700 Subject: [PATCH 615/781] Update main's Spend footer tests for the merchant contains operator --- tests/unit/Search/SearchQueryUtilsTest.ts | 3 ++- tests/unit/SearchParserTest.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index f856517af60a..f94025165124 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -2791,7 +2791,8 @@ describe('SearchQueryUtils', () => { // Captured before the footer keys existed. These hashes key saved searches and snapshots, so they must not drift. expect(buildSearchQueryJSON('type:expense')?.hash).toEqual(959171759); expect(buildSearchQueryJSON('type:expense-report')?.hash).toEqual(1657503970); - expect(buildSearchQueryJSON('type:expense merchant:Amazon')?.hash).toEqual(1692688529); + // `merchant:` parses as contains and hashes as `merchant*:`, so this hash differs from the one before the match type existed. + expect(buildSearchQueryJSON('type:expense merchant:Amazon')?.hash).toEqual(1712664595); }); }); diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index 5b3f0ed2e5cc..96b4cdc35431 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -1711,7 +1711,7 @@ const footerSelectionTests = [ right: 'USD', }, right: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'Amazon', }, From e0450911e0ead96cc8697377079d1d98d2871e96 Mon Sep 17 00:00:00 2001 From: allgandaf Date: Wed, 14 Oct 2026 08:51:48 +0530 Subject: [PATCH 616/781] test the share code page with only the QR code --- tests/unit/ShareCodePageTranslateTest.tsx | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/ShareCodePageTranslateTest.tsx b/tests/unit/ShareCodePageTranslateTest.tsx index 94d9f446909c..07299510fa6c 100644 --- a/tests/unit/ShareCodePageTranslateTest.tsx +++ b/tests/unit/ShareCodePageTranslateTest.tsx @@ -1,5 +1,6 @@ import {render} from '@testing-library/react-native'; +import ContextMenuItem from '@components/ContextMenuItem'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import QRShareWithDownload from '@components/QRShare/QRShareWithDownload'; @@ -54,6 +55,7 @@ jest.mock('@libs/ReportUtils', () => { const mockGetDisplayNameForParticipant = jest.mocked(getDisplayNameForParticipant); const mockFindLocalAvatarForURL = jest.mocked(findLocalAvatarForURL); const mockQRShareWithDownload = jest.mocked(QRShareWithDownload); +const mockContextMenuItem = jest.mocked(ContextMenuItem); const PARTICIPANT_A = 717001; const PARTICIPANT_B = 717002; @@ -90,6 +92,34 @@ describe('ShareCodePage', () => { expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({hiddenTranslation: 'common.hidden'})); }); + it('hides the rows under the QR code when only the QR code is asked for', async () => { + // Given a report, whose share code page normally lists Copy URL under the QR code + const report: Report = {reportID: '717200', type: CONST.REPORT.TYPE.EXPENSE}; + mockContextMenuItem.mockClear(); + render( + + + , + ); + await waitForBatchedUpdates(); + expect(mockContextMenuItem).toHaveBeenCalled(); + + // When the page opens from the Share menu, which already has Copy URL and Refer a friend + mockContextMenuItem.mockClear(); + render( + + + , + ); + await waitForBatchedUpdates(); + + // Then only the QR code shows + expect(mockContextMenuItem).not.toHaveBeenCalled(); + }); + describe('profile QR code avatar logo', () => { it('wraps catalog-backed avatars in an SVG logo that only forwards an explicitly set fill', async () => { const localAvatar: React.FC = jest.fn(() => null); From 4e48cfb53f40dcaf8e58dedde12929e294df6f5d Mon Sep 17 00:00:00 2001 From: allgandaf Date: Wed, 14 Oct 2026 08:56:23 +0530 Subject: [PATCH 617/781] keep the display name whisper after reacting to it --- src/libs/ReportActionsUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index e1c7a05acba0..72fd4507f0e7 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -463,6 +463,7 @@ function isDeletedAction(reportAction: OnyxInputOrEntry Date: Wed, 14 Oct 2026 08:56:24 +0530 Subject: [PATCH 618/781] add a test for the display name whisper with an empty message --- tests/unit/ReportActionsUtilsTest.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 3cc99fa60b54..daf8cbd2ccb3 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -2157,6 +2157,23 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.isDeletedAction(reportAction)).toBe(false); }); + it('should return false for POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_DISPLAY_NAME action with an empty message html', () => { + // Given the display name whisper as the backend sends it after a reaction, with an empty message html + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_DISPLAY_NAME, + reportActionID: '1', + created: '2026-10-13 12:00:03.573', + message: [{html: '', text: '', type: CONST.REPORT.MESSAGE.TYPE.COMMENT, whisperedTo: [590]}], + originalMessage: {oldValue: 'cathy@boulderdev.com', newValue: 'Cathy Miller', whisperedTo: [590]}, + }; + + // When checking whether it is deleted + const isDeleted = ReportActionsUtils.isDeletedAction(reportAction); + + // Then it is not, because its text comes from originalMessage + expect(isDeleted).toBe(false); + }); + it('should return false for CARDFROZEN action with a backend-provided message fragment', () => { const reportAction: ReportAction = { actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, From c30a0625d080a00f912bb836403aa17e7c3582b3 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Wed, 14 Oct 2026 04:07:02 +0000 Subject: [PATCH 619/781] Add useMergeTransactions test for a snapshot that has no data yet Co-authored-by: Kevin Brian Bader --- tests/unit/hooks/useMergeTransactionsTest.ts | 59 ++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/unit/hooks/useMergeTransactionsTest.ts diff --git a/tests/unit/hooks/useMergeTransactionsTest.ts b/tests/unit/hooks/useMergeTransactionsTest.ts new file mode 100644 index 000000000000..b5fc82f4d9d0 --- /dev/null +++ b/tests/unit/hooks/useMergeTransactionsTest.ts @@ -0,0 +1,59 @@ +import {renderHook, waitFor} from '@testing-library/react-native'; + +import useMergeTransactions from '@hooks/useMergeTransactions'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {MergeTransaction, SearchResults} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import createRandomTransaction from '../../utils/collections/transaction'; +import createMock from '../../utils/createMock'; + +let mockCurrentSearchResults: SearchResults | undefined; + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchQueryContext: () => ({ + currentSearchHash: 12345, + }), + useSearchResultsContext: () => ({ + currentSearchResults: mockCurrentSearchResults, + }), +})); + +jest.mock('@hooks/usePolicyForMovingExpenses', () => ({ + __esModule: true, + default: () => ({policyForMovingExpensesID: undefined}), +})); + +describe('useMergeTransactions', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + mockCurrentSearchResults = undefined; + }); + + it('falls back to the Onyx transactions while the search snapshot has no data yet', async () => { + // Given a merge started from search while the new search's snapshot only has `search` (optimistic loading state), + // with transactions that exist in Onyx but not in the merge draft, so the hook looks them up in the snapshot first + mockCurrentSearchResults = createMock({search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE}}); + const onyxTransaction1 = createRandomTransaction(1); + const onyxTransaction2 = createRandomTransaction(2); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, onyxTransaction1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}2`, onyxTransaction2); + const mergeTransaction = createMock({targetTransactionID: '1', sourceTransactionID: '2'}); + + // When the hook resolves the target and source transactions + const {result} = renderHook(() => useMergeTransactions({mergeTransaction})); + + // Then it falls back to the Onyx transactions instead of throwing on the missing snapshot data + await waitFor(() => { + expect(result.current.targetTransaction).toEqual(onyxTransaction1); + expect(result.current.sourceTransaction).toEqual(onyxTransaction2); + }); + }); +}); From 83b05f4f61011aa575552bc44e31d73168b733c9 Mon Sep 17 00:00:00 2001 From: "nkdengineer (via MelvinBot)" Date: Wed, 14 Oct 2026 04:34:43 +0000 Subject: [PATCH 620/781] Move PolicyUtils rules, feature, workflow, and error helpers into rules.ts, features.ts, workflows.ts, and errors.ts Co-authored-by: nkdengineer --- src/libs/PolicyUtils/errors.ts | 144 ++++++++++ src/libs/PolicyUtils/features.ts | 106 +++++++ src/libs/PolicyUtils/index.ts | 448 +----------------------------- src/libs/PolicyUtils/rules.ts | 147 ++++++++++ src/libs/PolicyUtils/workflows.ts | 73 +++++ 5 files changed, 482 insertions(+), 436 deletions(-) create mode 100644 src/libs/PolicyUtils/errors.ts create mode 100644 src/libs/PolicyUtils/features.ts create mode 100644 src/libs/PolicyUtils/rules.ts create mode 100644 src/libs/PolicyUtils/workflows.ts diff --git a/src/libs/PolicyUtils/errors.ts b/src/libs/PolicyUtils/errors.ts new file mode 100644 index 000000000000..19eb0be7e415 --- /dev/null +++ b/src/libs/PolicyUtils/errors.ts @@ -0,0 +1,144 @@ +/** + * Policy error flags, the brick-road indicator, and settings error and pending-action helpers. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {isQBORefreshTokenExpiringSoon} from '@libs/AccountingUtils'; +import {hasSynchronizationErrorMessage} from '@libs/actions/connections'; +import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; +import {isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; + +import CONST from '@src/CONST'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; +import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {getAccountingConnectionNames} from './connections'; +import {isPolicyAdmin} from './permissions'; + +/** + * Checks if we have any errors stored within the policy?.employeeList. Determines whether we should show a red brick road error or not. + */ +function shouldShowEmployeeListError(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) && Object.values(policy?.employeeList ?? {}).some((employee) => Object.keys(employee?.errors ?? {}).length > 0); +} + +/** + * Check if the policy has any tax rate errors. + */ +function shouldShowTaxRateError(policy: OnyxEntry): boolean { + return ( + isPolicyAdmin(policy) && + Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean)) + ); +} + +/** + * Check if the policy has any errors within the categories. + */ +function hasPolicyCategoriesError(policyCategories: OnyxEntry): boolean { + return Object.keys(policyCategories ?? {}).some((categoryName) => Object.keys(policyCategories?.[categoryName]?.errors ?? {}).length > 0); +} + +/** + * Check if the policy has any errors within the rules. + */ +function hasPolicyRulesError(policy: OnyxEntry, hasMerchantRuleErrors: boolean): boolean { + const agentRules = Object.values(policy?.rules?.agentRules ?? {}); + + return hasMerchantRuleErrors || agentRules.some((rule) => rule && Object.keys(rule.errors ?? {}).length > 0); +} + +/** + * Checks if the policy had a sync error. + */ +function shouldShowSyncError(policy: OnyxEntry, isSyncInProgress: boolean, connectionNames?: readonly ConnectionName[]): boolean { + const connectionsToCheck = connectionNames ?? (Object.keys(policy?.connections ?? {}) as ConnectionName[]); + return isPolicyAdmin(policy) && connectionsToCheck.some((connection) => !!hasSynchronizationErrorMessage(policy, connection, isSyncInProgress)); +} + +/** + * Check if the policy has any error fields. + */ +function shouldShowPolicyErrorFields(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) && Object.values(policy?.errorFields ?? {}).some((fieldErrors) => Object.keys(fieldErrors ?? {}).length > 0); +} + +/** + * Check if the policy has any errors, and if it doesn't, then check if it has any error fields. + */ +function shouldShowPolicyError(policy: OnyxEntry): boolean { + return Object.keys(policy?.errors ?? {}).length > 0 ? isPolicyAdmin(policy) : shouldShowPolicyErrorFields(policy); +} + +/** + * Checks if we have any errors stored within the policy custom units. + */ +function shouldShowCustomUnitsError(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) && Object.keys(policy?.customUnits?.errors ?? {}).length > 0; +} + +/** + * Get the brick road indicator status for a policy. The policy has an error status if there is a policy member error, a custom unit error or a field error. + */ +function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, isConnectionInProgress: boolean): ValueOf | undefined { + if ( + shouldShowEmployeeListError(policy) || + shouldShowCustomUnitsError(policy) || + shouldShowPolicyErrorFields(policy) || + shouldShowSyncError(policy, isConnectionInProgress, getAccountingConnectionNames()) || + shouldShowQBOReimbursableExportDestinationAccountError(policy) || + shouldShowHRConnectionError(policy, isConnectionInProgress, isPolicyAdmin(policy)) + ) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } + return undefined; +} + +/** + * Returns whether the Merge HR setup still needs to be completed for a policy. + */ +const isMergeHRCompleteSetupNeededSelector = (policy: OnyxEntry) => isMergeHRCompleteSetupNeeded(policy); + +/** + * Returns whether an admin should be warned that the workspace's QuickBooks Online connection is about to expire. + */ +const isQBORefreshTokenExpiringSoonSelector = (policy: OnyxEntry) => isPolicyAdmin(policy) && isQBORefreshTokenExpiringSoon(policy); + +function areSettingsInErrorFields(settings?: string[], errorFields?: ErrorFields) { + if (settings === undefined || errorFields === undefined) { + return false; + } + + const keys = Object.keys(errorFields); + return settings.some((setting) => keys.includes(setting)); +} + +function settingsPendingAction(settings?: string[], pendingFields?: PendingFields): PendingAction | undefined { + if (settings === undefined || pendingFields === undefined) { + return null; + } + + const key = Object.keys(pendingFields).find((setting) => settings.includes(setting)); + if (!key) { + return; + } + return pendingFields[key]; +} + +export { + shouldShowEmployeeListError, + shouldShowTaxRateError, + hasPolicyCategoriesError, + hasPolicyRulesError, + shouldShowSyncError, + shouldShowPolicyError, + shouldShowCustomUnitsError, + getPolicyBrickRoadIndicatorStatus, + isMergeHRCompleteSetupNeededSelector, + isQBORefreshTokenExpiringSoonSelector, + areSettingsInErrorFields, + settingsPendingAction, +}; diff --git a/src/libs/PolicyUtils/features.ts b/src/libs/PolicyUtils/features.ts new file mode 100644 index 000000000000..ef57eeeaa58c --- /dev/null +++ b/src/libs/PolicyUtils/features.ts @@ -0,0 +1,106 @@ +/** + * Whether a workspace feature is enabled, including rules, invoice fields, attendee tracking, time tracking, and MCP. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {hasAnyCategoryRules} from '@libs/CategoryUtils'; +import {convertToBackendAmount} from '@libs/CurrencyUtils'; +import {isAnyHRConnected} from '@libs/merge/HRUtils'; +import {isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; + +import CONST from '@src/CONST'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; +import type {PolicyFeatureName} from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {hasAccountingFeatureConnection} from './connections'; +import {canPolicyAccessFeature, isControlPolicy, isPaidGroupPolicy} from './policyType'; + +/** + * For backwards compatibility with Expensify Classic, attendee tracking defaults to enabled + * on Control policies when the property is undefined. + */ +function isAttendeeTrackingEnabled(policy: OnyxEntry): boolean { + return (isControlPolicy(policy) && policy?.isAttendeeTrackingEnabled) ?? true; +} + +function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null): boolean { + if (!isPaidGroupPolicy(policy)) { + return false; + } + if (policy?.areRulesEnabled === true) { + return true; + } + if (policy?.areRulesEnabled === false) { + return false; + } + // areRulesEnabled is undefined - this can happen in case of migrated old Control policies, in such case users might have set up category rules in Classic and we should show Rules as enabled + if (!isControlPolicy(policy)) { + return false; + } + return hasAnyCategoryRules(policyCategories ?? undefined); +} + +/** + * Whether Invoice Fields is enabled for the policy. + * Respects the `areInvoiceFieldsEnabled` toggle and verifies the policy has access to the feature (Control only). + */ +function isInvoiceFieldsEnabled(policy: OnyxEntry | null): boolean { + return !!policy?.areInvoiceFieldsEnabled && canPolicyAccessFeature(policy ?? undefined, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED); +} + +function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFeatureName, policyCategories?: PolicyCategories | null): boolean { + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED) { + return arePolicyRulesEnabled(policy, policyCategories); + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED) { + return isInvoiceFieldsEnabled(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { + return !!policy?.tax?.trackingEnabled; + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED) { + return policy?.[featureName] ? !!policy?.[featureName] : hasAccountingFeatureConnection(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED) { + return isMCPEnabled(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED) { + return policy?.isHREnabled === true || isAnyHRConnected(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED) { + return policy?.isRecruitingEnabled === true || isAnyRecruitingConnected(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED) { + return policy?.receiptPartners?.enabled ?? false; + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED) { + return isTimeTrackingEnabled(policy); + } + if (featureName === CONST.POLICY.MORE_FEATURES.IS_ATTENDEE_TRACKING_ENABLED) { + return isAttendeeTrackingEnabled(policy); + } + + return !!policy?.[featureName]; +} + +/** + * Returns if the policy has the Time Tracking feature enabled. + */ +function isTimeTrackingEnabled(policy: OnyxEntry): boolean { + return !!policy?.units?.time?.enabled; +} + +/** MCP is on for every workspace unless an admin has explicitly turned it off, so an absent flag reads as enabled. */ +function isMCPEnabled(policy: OnyxEntry): boolean { + return policy?.isMCPEnabled ?? true; +} + +/** + * Returns the policy's default hourly rate for the Time Tracking feature. + */ +function getDefaultTimeTrackingRate(policy: Partial>): number | undefined { + return policy?.units?.time?.rate !== undefined ? convertToBackendAmount(policy.units.time.rate) : undefined; +} + +export {isAttendeeTrackingEnabled, arePolicyRulesEnabled, isInvoiceFieldsEnabled, isPolicyFeatureEnabled, isTimeTrackingEnabled, isMCPEnabled, getDefaultTimeTrackingRate}; diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index c1d8e3fecc80..02bbd994fdab 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -1,17 +1,10 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; -import {isQBORefreshTokenExpiringSoon} from '@libs/AccountingUtils'; import {getBankAccountFromID} from '@libs/actions/BankAccounts'; -import {hasSynchronizationErrorMessage} from '@libs/actions/connections'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import {getApiRoot} from '@libs/ApiUtils'; -import {getCategoryApproverRule, hasAnyCategoryRules} from '@libs/CategoryUtils'; -import {convertToBackendAmount} from '@libs/CurrencyUtils'; +import {getCategoryApproverRule} from '@libs/CategoryUtils'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {getHRAdvancedModeFinalApprover, isAnyHRConnected, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; -import {isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; +import {getHRAdvancedModeFinalApprover} from '@libs/merge/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getIsOffline} from '@libs/NetworkState'; import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; @@ -25,10 +18,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {PolicyType} from '@src/types/form/WorkspaceConfirmationForm'; -import type {Card, OnyxInputOrEntry, PersonalDetailsList, Policy, PolicyCategories, PolicyEmployeeList, Report, TravelSettings} from '@src/types/onyx'; +import type {Card, OnyxInputOrEntry, PersonalDetailsList, Policy, PolicyEmployeeList, Report, TravelSettings} from '@src/types/onyx'; import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; -import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; -import type {ApprovalRule, CommuterExclusions, ConnectionName, PolicyFeatureName} from '@src/types/onyx/Policy'; +import type {ApprovalRule, CommuterExclusions} from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import type Rule from '@src/types/onyx/Rule'; import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; @@ -40,10 +32,12 @@ import type {ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; -import {getAccountingConnectionNames, hasAccountingFeatureConnection} from './connections'; import {getPerDiemCustomUnit, isPerDiemEligiblePolicy} from './customUnits'; +import {isTimeTrackingEnabled} from './features'; import {canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyApprover, isPolicyOwner, isPolicyUser} from './permissions'; -import {canPolicyAccessFeature, isArchivedPolicy, isCollectPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; +import {isArchivedPolicy, isCollectPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; +import {getTagApproverRule} from './rules'; +import {isSubmitAndClose} from './workflows'; type MemberEmailsToAccountIDs = Record; @@ -131,68 +125,6 @@ function getActivePoliciesWithExpenseChatAndTimeEnabled(policies: OnyxCollection return getActivePoliciesWithExpenseChat(policies, currentUserLogin).filter(isTimeTrackingEnabled); } -/** - * Checks if we have any errors stored within the policy?.employeeList. Determines whether we should show a red brick road error or not. - */ -function shouldShowEmployeeListError(policy: OnyxEntry): boolean { - return isPolicyAdmin(policy) && Object.values(policy?.employeeList ?? {}).some((employee) => Object.keys(employee?.errors ?? {}).length > 0); -} - -/** - * Check if the policy has any tax rate errors. - */ -function shouldShowTaxRateError(policy: OnyxEntry): boolean { - return ( - isPolicyAdmin(policy) && - Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean)) - ); -} - -/** - * Check if the policy has any errors within the categories. - */ -function hasPolicyCategoriesError(policyCategories: OnyxEntry): boolean { - return Object.keys(policyCategories ?? {}).some((categoryName) => Object.keys(policyCategories?.[categoryName]?.errors ?? {}).length > 0); -} - -/** - * Check if the policy has any errors within the rules. - */ -function hasPolicyRulesError(policy: OnyxEntry, hasMerchantRuleErrors: boolean): boolean { - const agentRules = Object.values(policy?.rules?.agentRules ?? {}); - - return hasMerchantRuleErrors || agentRules.some((rule) => rule && Object.keys(rule.errors ?? {}).length > 0); -} - -/** - * Checks if the policy had a sync error. - */ -function shouldShowSyncError(policy: OnyxEntry, isSyncInProgress: boolean, connectionNames?: readonly ConnectionName[]): boolean { - const connectionsToCheck = connectionNames ?? (Object.keys(policy?.connections ?? {}) as ConnectionName[]); - return isPolicyAdmin(policy) && connectionsToCheck.some((connection) => !!hasSynchronizationErrorMessage(policy, connection, isSyncInProgress)); -} - -/** - * Check if the policy has any error fields. - */ -function shouldShowPolicyErrorFields(policy: OnyxEntry): boolean { - return isPolicyAdmin(policy) && Object.values(policy?.errorFields ?? {}).some((fieldErrors) => Object.keys(fieldErrors ?? {}).length > 0); -} - -/** - * Check if the policy has any errors, and if it doesn't, then check if it has any error fields. - */ -function shouldShowPolicyError(policy: OnyxEntry): boolean { - return Object.keys(policy?.errors ?? {}).length > 0 ? isPolicyAdmin(policy) : shouldShowPolicyErrorFields(policy); -} - -/** - * Checks if we have any errors stored within the policy custom units. - */ -function shouldShowCustomUnitsError(policy: OnyxEntry): boolean { - return isPolicyAdmin(policy) && Object.keys(policy?.customUnits?.errors ?? {}).length > 0; -} - /** * The workspace-wide work arrangement, which members follow unless they were given one of their own. */ @@ -247,33 +179,6 @@ function hasEligibleBankAccountShareRecipient(policies: OnyxCollection | return getEligibleBankAccountShareRecipientEmails(policies, currentUserLogin, bankAccountID).length > 0; } -/** - * Get the brick road indicator status for a policy. The policy has an error status if there is a policy member error, a custom unit error or a field error. - */ -function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, isConnectionInProgress: boolean): ValueOf | undefined { - if ( - shouldShowEmployeeListError(policy) || - shouldShowCustomUnitsError(policy) || - shouldShowPolicyErrorFields(policy) || - shouldShowSyncError(policy, isConnectionInProgress, getAccountingConnectionNames()) || - shouldShowQBOReimbursableExportDestinationAccountError(policy) || - shouldShowHRConnectionError(policy, isConnectionInProgress, isPolicyAdmin(policy)) - ) { - return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } - return undefined; -} - -/** - * Returns whether the Merge HR setup still needs to be completed for a policy. - */ -const isMergeHRCompleteSetupNeededSelector = (policy: OnyxEntry) => isMergeHRCompleteSetupNeeded(policy); - -/** - * Returns whether an admin should be warned that the workspace's QuickBooks Online connection is about to expire. - */ -const isQBORefreshTokenExpiringSoonSelector = (policy: OnyxEntry) => isPolicyAdmin(policy) && isQBORefreshTokenExpiringSoon(policy); - /** * Check if the policy can be displayed * If shouldShowPendingDeletePolicy is true, show the policy pending deletion. @@ -687,100 +592,6 @@ function filterGuideAndAccountManager): boolean { - if (!policyCategories) { - return false; - } - - const defaultCategoryNames = new Set(Object.values(CONST.POLICY.DEFAULT_CATEGORIES)); - - return Object.values(policyCategories).some((category) => category && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !defaultCategoryNames.has(category.name)); -} - -/** - * Checks whether a policy max expense amount (e.g. `maxExpenseAmountNoReceipt`) is actually configured. - * - * `0` is a valid limit meaning "always required", so a falsy check would wrongly treat an explicit $0.00 as unset. - * Only `undefined` and `CONST.DISABLED_MAX_EXPENSE_VALUE` mean the amount is not set. - */ -function isMaxExpenseAmountSet(value: number | undefined): value is number { - return value !== undefined && value !== CONST.DISABLED_MAX_EXPENSE_VALUE; -} - -/** - * Checks if a policy has any rules configured (structured rules, individual expense limits, or prohibited expenses). - */ -function hasConfiguredRules(policy: OnyxEntry, policyCategories: PolicyCategories | null | undefined, hasExpenseDefaultRules: boolean): boolean { - if (!policy) { - return false; - } - - if (hasExpenseDefaultRules) { - return true; - } - - if (!!policy.customRules && policy.customRules.trim().length > 0) { - return true; - } - - const {rules: policyRules} = policy; - if (!!policyRules?.approvalRules && policyRules.approvalRules.length > 0) { - return true; - } - if (!!policyRules?.expenseRules && policyRules.expenseRules.length > 0) { - return true; - } - if (!!policy.maxExpenseAmount && policy.maxExpenseAmount !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAmount !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT) { - return true; - } - if (!!policy.maxExpenseAge && policy.maxExpenseAge !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAge !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE) { - return true; - } - if ( - !!policy.maxExpenseAmountNoReceipt && - policy.maxExpenseAmountNoReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && - policy.maxExpenseAmountNoReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT - ) { - return true; - } - if ( - !!policy.maxExpenseAmountNoItemizedReceipt && - policy.maxExpenseAmountNoItemizedReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && - policy.maxExpenseAmountNoItemizedReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT - ) { - return true; - } - - if (policy.defaultBillable) { - return true; - } - if (policy.defaultReimbursable === false) { - return true; - } - if (policy.eReceipts) { - return true; - } - if (policy.requireCompanyCardsEnabled) { - return true; - } - - const {prohibitedExpenses} = policy; - if ( - !!prohibitedExpenses && - Object.entries(CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES).some(([key, defaultValue]) => { - const value = prohibitedExpenses[key as keyof typeof CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES]; - return value !== undefined && value !== defaultValue; - }) - ) { - return true; - } - - return hasAnyCategoryRules(policyCategories ?? undefined); -} - function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number | undefined): Policy[] { return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID) && isPaidGroupPolicy(policy)); } @@ -800,14 +611,6 @@ function tryNavigateToSubmitWorkspaceUpgrade(policy: OnyxEntry, isEnabli return true; } -/** - * For backwards compatibility with Expensify Classic, attendee tracking defaults to enabled - * on Control policies when the property is undefined. - */ -function isAttendeeTrackingEnabled(policy: OnyxEntry): boolean { - return (isControlPolicy(policy) && policy?.isAttendeeTrackingEnabled) ?? true; -} - /** * Collect workspaces can access a limited subset of Rules features. When a Collect admin tries to * access a Control-only Rules feature, navigate to the upgrade flow and return true. @@ -825,51 +628,6 @@ function tryNavigateToControlPolicyUpgrade(policy: OnyxEntry, upgradeFea return true; } -/** - * Checks if policy's scheduled submit / auto reporting frequency is "instant". - * Note: Free policies have "instant" submit always enabled. - */ -function isInstantSubmitEnabled(policy: OnyxInputOrEntry): boolean { - return policy?.autoReporting === true && policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; -} - -/** - * Checks if policy's scheduled submit / auto reporting frequency is not "instant". - */ -function isDelayedSubmissionEnabled(policy: OnyxInputOrEntry): boolean { - return policy?.autoReporting === true && policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; -} - -/** - * This gets a "corrected" value for autoReportingFrequency. The purpose of this function is to encapsulate some logic around the "immediate" frequency. - * - * - "immediate" is actually not immediate. For that you want "instant". - * - (immediate && harvesting.enabled) === daily - * - (immediate && !harvesting.enabled) === manual - * - * Note that "daily" and "manual" only exist as options for the API, not in the database or Onyx. - */ -function getCorrectedAutoReportingFrequency(policy: OnyxInputOrEntry): ValueOf | undefined { - if (policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE) { - return policy?.autoReportingFrequency; - } - - if (policy?.harvesting?.enabled) { - // This is actually not really "immediate". It's "daily". Surprise! - return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE; - } - - // "manual" is really just "immediate" (aka "daily") with harvesting disabled - return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; -} - -/** - * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" - */ -function isSubmitAndClose(policy: OnyxInputOrEntry): boolean { - return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; -} - /** * Resolves a workspace's reimbursement choice to one of the three values in `CONST.POLICY.REIMBURSEMENT_CHOICES`. * Comparing the raw field instead makes a workspace reporting a deprecated value look like it has reimbursement @@ -926,87 +684,10 @@ function isControlOnAdvancedApprovalMode(policy: OnyxInputOrEntry): bool return policy?.type === CONST.POLICY.TYPE.CORPORATE && getApprovalWorkflow(policy) === CONST.POLICY.APPROVAL_MODE.ADVANCED; } -/** - * Checks if policy has Dynamic External Workflow enabled - */ -function hasDynamicExternalWorkflow(policy: OnyxEntry): boolean { - return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; -} - -/** - * Checks if the approval workflow configuration must stay hidden because the workspace's Dynamic External Workflow has - * "Hide People Table Columns" set. The backend only returns `dynamicExternalWorkflowHidePeople` when it is `true`, so an - * absent flag means "show". The approval mode is checked too, so a stale flag can't hide workflows on a policy that no - * longer uses a Dynamic External Workflow. - */ -function shouldHideDynamicExternalWorkflowPeople(policy: OnyxEntry): boolean { - return hasDynamicExternalWorkflow(policy) && !!policy?.dynamicExternalWorkflowHidePeople; -} - function goBackFromInvalidPolicy() { Navigation.goBack(ROUTES.WORKSPACES_LIST.route); } -function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null): boolean { - if (!isPaidGroupPolicy(policy)) { - return false; - } - if (policy?.areRulesEnabled === true) { - return true; - } - if (policy?.areRulesEnabled === false) { - return false; - } - // areRulesEnabled is undefined - this can happen in case of migrated old Control policies, in such case users might have set up category rules in Classic and we should show Rules as enabled - if (!isControlPolicy(policy)) { - return false; - } - return hasAnyCategoryRules(policyCategories ?? undefined); -} - -/** - * Whether Invoice Fields is enabled for the policy. - * Respects the `areInvoiceFieldsEnabled` toggle and verifies the policy has access to the feature (Control only). - */ -function isInvoiceFieldsEnabled(policy: OnyxEntry | null): boolean { - return !!policy?.areInvoiceFieldsEnabled && canPolicyAccessFeature(policy ?? undefined, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED); -} - -function isPolicyFeatureEnabled(policy: OnyxEntry, featureName: PolicyFeatureName, policyCategories?: PolicyCategories | null): boolean { - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED) { - return arePolicyRulesEnabled(policy, policyCategories); - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED) { - return isInvoiceFieldsEnabled(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { - return !!policy?.tax?.trackingEnabled; - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED) { - return policy?.[featureName] ? !!policy?.[featureName] : hasAccountingFeatureConnection(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED) { - return isMCPEnabled(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED) { - return policy?.isHREnabled === true || isAnyHRConnected(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED) { - return policy?.isRecruitingEnabled === true || isAnyRecruitingConnected(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED) { - return policy?.receiptPartners?.enabled ?? false; - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED) { - return isTimeTrackingEnabled(policy); - } - if (featureName === CONST.POLICY.MORE_FEATURES.IS_ATTENDEE_TRACKING_ENABLED) { - return isAttendeeTrackingEnabled(policy); - } - - return !!policy?.[featureName]; -} - function getApprovalWorkflow(policy: OnyxEntry): ValueOf { if (policy?.type === CONST.POLICY.TYPE.PERSONAL) { return CONST.POLICY.APPROVAL_MODE.OPTIONAL; @@ -1423,27 +1104,6 @@ function canSendInvoice(policies: OnyxCollection | null, currentUserLogi return getActiveAdminWorkspaces(policies, currentUserLogin).some((policy) => canSendInvoiceFromWorkspace(policy)); } -function areSettingsInErrorFields(settings?: string[], errorFields?: ErrorFields) { - if (settings === undefined || errorFields === undefined) { - return false; - } - - const keys = Object.keys(errorFields); - return settings.some((setting) => keys.includes(setting)); -} - -function settingsPendingAction(settings?: string[], pendingFields?: PendingFields): PendingAction | undefined { - if (settings === undefined || pendingFields === undefined) { - return null; - } - - const key = Object.keys(pendingFields).find((setting) => settings.includes(setting)); - if (!key) { - return; - } - return pendingFields[key]; -} - /** * Sort the workspaces by their name, while keeping the selected one at the beginning. * @param workspace1 Details of the first workspace to be compared. @@ -1491,19 +1151,6 @@ function hasOnlyPersonalPolicies(policies: OnyxCollection) { return !Object.values(policies ?? {}).some((policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); } -function getTagApproverRule(policy: OnyxEntry, tagName: string) { - if (!policy) { - return; - } - - const approvalRules = policy.rules?.approvalRules ?? []; - const approverRule = approvalRules.find((rule) => - rule.applyWhen.find(({condition, field, value}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.TAG && value === tagName), - ); - - return approverRule; -} - function getDomainNameForPolicy(policyID?: string): string { if (!policyID) { return ''; @@ -1805,56 +1452,11 @@ function getTravelStep( return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; } -/** - * Returns if the policy has the Time Tracking feature enabled. - */ -function isTimeTrackingEnabled(policy: OnyxEntry): boolean { - return !!policy?.units?.time?.enabled; -} - -/** MCP is on for every workspace unless an admin has explicitly turned it off, so an absent flag reads as enabled. */ -function isMCPEnabled(policy: OnyxEntry): boolean { - return policy?.isMCPEnabled ?? true; -} - -/** - * Returns the policy's default hourly rate for the Time Tracking feature. - */ -function getDefaultTimeTrackingRate(policy: Partial>): number | undefined { - return policy?.units?.time?.rate !== undefined ? convertToBackendAmount(policy.units.time.rate) : undefined; -} - function sortPoliciesByName(policies: Policy[], localeCompare: (a: string, b: string) => number): Policy[] { // copy first: callers pass memoized selector output, sorting it in place makes the next deepEqual report a change return [...policies].sort((a, b) => localeCompare(a.name || '', b.name || '')); } -/** - * Builds a source URL for rendering a policy document PDF. - * Local blob/file URIs (from optimistic uploads) are returned directly. - * Remote URLs are routed through the authenticated GetPolicyRulesDocument streaming endpoint. - * The stored URL (which contains a unique timestamp per upload) is appended as a version - * parameter so the browser treats each replacement as a distinct resource. - */ -function getRulesDocumentSourceURL(rulesDocumentURL: string | undefined, policyID: string | undefined, encryptedAuthToken: string): string { - if (!rulesDocumentURL || !policyID) { - return ''; - } - - const isLocalFile = rulesDocumentURL.startsWith('blob:') || rulesDocumentURL.startsWith('file:'); - if (isLocalFile) { - return rulesDocumentURL; - } - - return addEncryptedAuthTokenToURL( - // Each PDF upload gets a unique S3 key, so rulesDocumentURL changes on every replacement. - // Encoding it as cacheBuster ensures the full streaming URL is also unique, preventing stale browser/pdfjs cache. - `${getApiRoot({shouldUseSecure: false})}api/GetPolicyRulesDocument?policyID=${policyID}&cacheBuster=${encodeURIComponent(rulesDocumentURL)}`, - encryptedAuthToken, - true, - ); -} - export { getActivePolicies, getActivePoliciesWithExpenseChat, @@ -1867,29 +1469,13 @@ export { getSoftExclusionsForGuideAndAccountManager, getExpensifyTeamExclusions, filterGuideAndAccountManager, - getPolicyBrickRoadIndicatorStatus, - hasCustomCategories, - hasConfiguredRules, - isMaxExpenseAmountSet, goBackFromInvalidPolicy, - shouldShowSyncError, - shouldShowCustomUnitsError, - shouldShowEmployeeListError, - hasPolicyCategoriesError, - hasPolicyRulesError, - shouldShowPolicyError, - shouldShowTaxRateError, isExpensifyTeam, shouldFilterExpensifyTeam, createFilteredMemberCountSelector, createInvoiceConfigurationTextSelector, isDeletedPolicyEmployee, - isInstantSubmitEnabled, - isDelayedSubmissionEnabled, - getCorrectedAutoReportingFrequency, hasEligibleBankAccountShareRecipient, - arePolicyRulesEnabled, - isPolicyFeatureEnabled, isPolicyFieldListEmpty, canUnarchivePolicy, isPolicyMember, @@ -1901,7 +1487,6 @@ export { isAutoPayApprovedReportsAvailable, getReimbursementChoice, isSubmitterAndApprover, - isSubmitAndClose, shouldShowPolicy, getActiveAdminWorkspaces, hasActiveAdminWorkspaces, @@ -1920,10 +1505,7 @@ export { hasCustomApprovalWorkflow, getApprovalWorkflow, getReimburserAccountID, - isAttendeeTrackingEnabled, hasOnlyPersonalPolicies, - areSettingsInErrorFields, - settingsPendingAction, getGroupPaidPolicies, getGroupPoliciesWhereReportCanBeCreated, getDefaultChatEnabledPolicy, @@ -1933,7 +1515,6 @@ export { evaluateApprovalWorkflowRule, getSubmitToAccountID, getSubmitReportManagerAccountID, - getTagApproverRule, getDomainNameForPolicy, getWorkflowApprovalsUnavailable, getUserFriendlyWorkspaceType, @@ -1953,27 +1534,18 @@ export { getManagerAccountID, getActiveEmployeeWorkspaces, isPolicyMemberWithoutPendingDelete, - hasDynamicExternalWorkflow, - shouldHideDynamicExternalWorkflowPeople, getActivePoliciesWithExpenseChatAndPerDiemEnabled, getPoliciesForRoomCreation, - isInvoiceFieldsEnabled, getTravelStep, isWorkspaceProvisionedForTravel, hasAcceptedTravelTerms, isNonUSDPolicy, - isTimeTrackingEnabled, - isMCPEnabled, - getDefaultTimeTrackingRate, getActivePoliciesWithExpenseChatAndTimeEnabled, sortPoliciesByName, hasActiveExpensifyCard, tryNavigateToSubmitWorkspaceUpgrade, tryNavigateToControlPolicyUpgrade, - getRulesDocumentSourceURL, hasAnyPaidPolicy, - isMergeHRCompleteSetupNeededSelector, - isQBORefreshTokenExpiringSoonSelector, }; // Re-exported with `export *` rather than through the block above: a named re-export becomes a getter that throws @@ -1988,5 +1560,9 @@ export * from './xero'; export * from './sageIntacct'; export * from './customUnits'; export * from './tax'; +export * from './rules'; +export * from './features'; +export * from './workflows'; +export * from './errors'; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/PolicyUtils/rules.ts b/src/libs/PolicyUtils/rules.ts new file mode 100644 index 000000000000..6bc7d617f519 --- /dev/null +++ b/src/libs/PolicyUtils/rules.ts @@ -0,0 +1,147 @@ +/** + * Custom categories, max expense limits, configured-rule checks, tag approver rules, and the rules document URL. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {getApiRoot} from '@libs/ApiUtils'; +import {hasAnyCategoryRules} from '@libs/CategoryUtils'; + +import CONST from '@src/CONST'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +/** + * Checks if a policy has any custom categories (categories not in the default list) + */ +function hasCustomCategories(policyCategories: OnyxEntry): boolean { + if (!policyCategories) { + return false; + } + + const defaultCategoryNames = new Set(Object.values(CONST.POLICY.DEFAULT_CATEGORIES)); + + return Object.values(policyCategories).some((category) => category && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !defaultCategoryNames.has(category.name)); +} + +/** + * Checks whether a policy max expense amount (e.g. `maxExpenseAmountNoReceipt`) is actually configured. + * + * `0` is a valid limit meaning "always required", so a falsy check would wrongly treat an explicit $0.00 as unset. + * Only `undefined` and `CONST.DISABLED_MAX_EXPENSE_VALUE` mean the amount is not set. + */ +function isMaxExpenseAmountSet(value: number | undefined): value is number { + return value !== undefined && value !== CONST.DISABLED_MAX_EXPENSE_VALUE; +} + +/** + * Checks if a policy has any rules configured (structured rules, individual expense limits, or prohibited expenses). + */ +function hasConfiguredRules(policy: OnyxEntry, policyCategories: PolicyCategories | null | undefined, hasExpenseDefaultRules: boolean): boolean { + if (!policy) { + return false; + } + + if (hasExpenseDefaultRules) { + return true; + } + + if (!!policy.customRules && policy.customRules.trim().length > 0) { + return true; + } + + const {rules: policyRules} = policy; + if (!!policyRules?.approvalRules && policyRules.approvalRules.length > 0) { + return true; + } + if (!!policyRules?.expenseRules && policyRules.expenseRules.length > 0) { + return true; + } + if (!!policy.maxExpenseAmount && policy.maxExpenseAmount !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAmount !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT) { + return true; + } + if (!!policy.maxExpenseAge && policy.maxExpenseAge !== CONST.DISABLED_MAX_EXPENSE_VALUE && policy.maxExpenseAge !== CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE) { + return true; + } + if ( + !!policy.maxExpenseAmountNoReceipt && + policy.maxExpenseAmountNoReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && + policy.maxExpenseAmountNoReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT + ) { + return true; + } + if ( + !!policy.maxExpenseAmountNoItemizedReceipt && + policy.maxExpenseAmountNoItemizedReceipt !== CONST.DISABLED_MAX_EXPENSE_VALUE && + policy.maxExpenseAmountNoItemizedReceipt !== CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT + ) { + return true; + } + + if (policy.defaultBillable) { + return true; + } + if (policy.defaultReimbursable === false) { + return true; + } + if (policy.eReceipts) { + return true; + } + if (policy.requireCompanyCardsEnabled) { + return true; + } + + const {prohibitedExpenses} = policy; + if ( + !!prohibitedExpenses && + Object.entries(CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES).some(([key, defaultValue]) => { + const value = prohibitedExpenses[key as keyof typeof CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES]; + return value !== undefined && value !== defaultValue; + }) + ) { + return true; + } + + return hasAnyCategoryRules(policyCategories ?? undefined); +} + +function getTagApproverRule(policy: OnyxEntry, tagName: string) { + if (!policy) { + return; + } + + const approvalRules = policy.rules?.approvalRules ?? []; + const approverRule = approvalRules.find((rule) => + rule.applyWhen.find(({condition, field, value}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.TAG && value === tagName), + ); + + return approverRule; +} + +/** + * Builds a source URL for rendering a policy document PDF. + * Local blob/file URIs (from optimistic uploads) are returned directly. + * Remote URLs are routed through the authenticated GetPolicyRulesDocument streaming endpoint. + * The stored URL (which contains a unique timestamp per upload) is appended as a version + * parameter so the browser treats each replacement as a distinct resource. + */ +function getRulesDocumentSourceURL(rulesDocumentURL: string | undefined, policyID: string | undefined, encryptedAuthToken: string): string { + if (!rulesDocumentURL || !policyID) { + return ''; + } + + const isLocalFile = rulesDocumentURL.startsWith('blob:') || rulesDocumentURL.startsWith('file:'); + if (isLocalFile) { + return rulesDocumentURL; + } + + return addEncryptedAuthTokenToURL( + // Each PDF upload gets a unique S3 key, so rulesDocumentURL changes on every replacement. + // Encoding it as cacheBuster ensures the full streaming URL is also unique, preventing stale browser/pdfjs cache. + `${getApiRoot({shouldUseSecure: false})}api/GetPolicyRulesDocument?policyID=${policyID}&cacheBuster=${encodeURIComponent(rulesDocumentURL)}`, + encryptedAuthToken, + true, + ); +} + +export {hasCustomCategories, isMaxExpenseAmountSet, hasConfiguredRules, getTagApproverRule, getRulesDocumentSourceURL}; diff --git a/src/libs/PolicyUtils/workflows.ts b/src/libs/PolicyUtils/workflows.ts new file mode 100644 index 000000000000..c144e0bf5388 --- /dev/null +++ b/src/libs/PolicyUtils/workflows.ts @@ -0,0 +1,73 @@ +/** + * Auto-reporting (scheduled submit) frequency, Submit & Close, and Dynamic External Workflow. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +/** + * Checks if policy's scheduled submit / auto reporting frequency is "instant". + * Note: Free policies have "instant" submit always enabled. + */ +function isInstantSubmitEnabled(policy: OnyxInputOrEntry): boolean { + return policy?.autoReporting === true && policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; +} + +/** + * Checks if policy's scheduled submit / auto reporting frequency is not "instant". + */ +function isDelayedSubmissionEnabled(policy: OnyxInputOrEntry): boolean { + return policy?.autoReporting === true && policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; +} + +/** + * This gets a "corrected" value for autoReportingFrequency. The purpose of this function is to encapsulate some logic around the "immediate" frequency. + * + * - "immediate" is actually not immediate. For that you want "instant". + * - (immediate && harvesting.enabled) === daily + * - (immediate && !harvesting.enabled) === manual + * + * Note that "daily" and "manual" only exist as options for the API, not in the database or Onyx. + */ +function getCorrectedAutoReportingFrequency(policy: OnyxInputOrEntry): ValueOf | undefined { + if (policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE) { + return policy?.autoReportingFrequency; + } + + if (policy?.harvesting?.enabled) { + // This is actually not really "immediate". It's "daily". Surprise! + return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE; + } + + // "manual" is really just "immediate" (aka "daily") with harvesting disabled + return CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL; +} + +/** + * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" + */ +function isSubmitAndClose(policy: OnyxInputOrEntry): boolean { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; +} + +/** + * Checks if policy has Dynamic External Workflow enabled + */ +function hasDynamicExternalWorkflow(policy: OnyxEntry): boolean { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; +} + +/** + * Checks if the approval workflow configuration must stay hidden because the workspace's Dynamic External Workflow has + * "Hide People Table Columns" set. The backend only returns `dynamicExternalWorkflowHidePeople` when it is `true`, so an + * absent flag means "show". The approval mode is checked too, so a stale flag can't hide workflows on a policy that no + * longer uses a Dynamic External Workflow. + */ +function shouldHideDynamicExternalWorkflowPeople(policy: OnyxEntry): boolean { + return hasDynamicExternalWorkflow(policy) && !!policy?.dynamicExternalWorkflowHidePeople; +} + +export {isInstantSubmitEnabled, isDelayedSubmissionEnabled, getCorrectedAutoReportingFrequency, isSubmitAndClose, hasDynamicExternalWorkflow, shouldHideDynamicExternalWorkflowPeople}; From 4c51bdc71992d8fcd58a852b503167940357ae3c Mon Sep 17 00:00:00 2001 From: "nkdengineer (via MelvinBot)" Date: Wed, 14 Oct 2026 04:35:31 +0000 Subject: [PATCH 621/781] Move the PolicyUtils seatbelt entries to errors.ts and rules.ts Co-authored-by: nkdengineer --- config/eslint/eslint.seatbelt.tsv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 968073a518b3..62d495e48b87 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -897,7 +897,8 @@ "../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/PhoneNumber.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/PolicyUtils/connections.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/libs/PolicyUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/libs/PolicyUtils/errors.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/libs/PolicyUtils/rules.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/prepareRequestPayload/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/libs/prepareRequestPayload/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Pusher/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 From 8ee92fa2a1324537ad638d55ee93c4a17f32824f Mon Sep 17 00:00:00 2001 From: "nkdengineer (via MelvinBot)" Date: Wed, 14 Oct 2026 04:37:22 +0000 Subject: [PATCH 622/781] Spread workflows into PolicyUtils mocks that load inside an import cycle Co-authored-by: nkdengineer --- tests/actions/IOU/SplitReportTotalsTest.ts | 1 + tests/unit/ReportSecondaryActionUtilsTest.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index d7bbd958b9f3..765dd9eb4c60 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -95,6 +95,7 @@ jest.mock('@libs/PolicyUtils', () => ({ // aren't on it yet. Spread them from their own modules, which load fully. ...jest.requireActual>('@libs/PolicyUtils/policyType'), ...jest.requireActual>('@libs/PolicyUtils/permissions'), + ...jest.requireActual>('@libs/PolicyUtils/workflows'), isPaidGroupPolicy: jest.fn().mockReturnValue(true), isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), })); diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 16471032dbf1..0e214ffb5b74 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -114,6 +114,7 @@ jest.mock('@libs/PolicyUtils', () => ({ // aren't on it yet. Spread them from their own modules, which load fully. ...jest.requireActual>('@libs/PolicyUtils/policyType'), ...jest.requireActual>('@libs/PolicyUtils/permissions'), + ...jest.requireActual>('@libs/PolicyUtils/workflows'), isPreferredExporter: jest.fn().mockReturnValue(true), hasAccountingConnections: jest.fn().mockReturnValue(true), isPolicyAdmin: jest.fn().mockReturnValue(true), From 1bb6487bf535165115b6e197b5a5f43e17007e6b Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Tue, 13 Oct 2026 22:35:20 -0700 Subject: [PATCH 623/781] fix(app): limit NVP visibility updates to support tickets --- .../configs/visibleReportActions.ts | 23 +++++++++---------- .../OnyxDerived/visibleReportActionsTest.ts | 17 ++++++++++---- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts index 6507142a463c..9d6173058431 100644 --- a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts @@ -5,7 +5,7 @@ import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDer import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction, ReportActions, ReportNameValuePairs} from '@src/types/onyx'; +import type {ReportAction, ReportActions} from '@src/types/onyx'; import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; /** @@ -24,11 +24,10 @@ function shouldSkipCachingAction(action: ReportAction): boolean { function computeReportVisibility( reportActions: ReportActions, currentUserAccountID: number | undefined, - report: Report | undefined, - reportNameValuePairs: ReportNameValuePairs | undefined, + isSupportTicketReport: boolean, + isReplacedSupportTicket: boolean, ): Record { const reportVisibility: Record = {}; - const isSupportTicketReport = isSupportTicket(report); for (const [actionID, action] of Object.entries(reportActions)) { if (!action) { @@ -43,7 +42,7 @@ function computeReportVisibility( continue; } // A replacement ticket has its own active survey, so the original ticket's survey is stale. - if (isSupportTicketReport && reportNameValuePairs?.reopenedAsReportID && isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY)) { + if (isReplacedSupportTicket && isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY)) { reportVisibility[action.reportActionID] = false; continue; } @@ -80,7 +79,9 @@ export default createOnyxDerivedValueConfig({ const reportIDsToProcess = isIncremental ? new Set([ ...Object.keys(reportActionsUpdates ?? {}).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, '')), - ...Object.keys(reportNameValuePairsUpdates ?? {}).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, '')), + ...Object.keys(reportNameValuePairsUpdates ?? {}) + .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, '')) + .filter((reportID) => isSupportTicket(allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`])), ]) : new Set(Object.keys(allReportActions).map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''))); @@ -94,12 +95,10 @@ export default createOnyxDerivedValueConfig({ continue; } - result[reportID] = computeReportVisibility( - reportActions, - currentUserAccountID, - allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`], - allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`], - ); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const isSupportTicketReport = isSupportTicket(report); + const isReplacedSupportTicket = isSupportTicketReport && !!allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]?.reopenedAsReportID; + result[reportID] = computeReportVisibility(reportActions, currentUserAccountID, isSupportTicketReport, isReplacedSupportTicket); } return result; diff --git a/tests/unit/OnyxDerived/visibleReportActionsTest.ts b/tests/unit/OnyxDerived/visibleReportActionsTest.ts index 86af1538fc7d..0539db5c40c4 100644 --- a/tests/unit/OnyxDerived/visibleReportActionsTest.ts +++ b/tests/unit/OnyxDerived/visibleReportActionsTest.ts @@ -42,6 +42,8 @@ describe('visibleReportActions', () => { const reportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`; const unrelatedReportID = 'unrelatedReportID'; const unrelatedActionID = 'unrelatedActionID'; + const unrelatedReportKey = `${ONYXKEYS.COLLECTION.REPORT}${unrelatedReportID}`; + const unrelatedReportNameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${unrelatedReportID}`; const allReportActions: OnyxCollection = { [reportActionsKey]: { [surveyActionID]: { @@ -54,7 +56,7 @@ describe('visibleReportActions', () => { const args: Parameters[0] = [ allReportActions, undefined, - {[reportKey]: {reportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}}, + {[reportKey]: {reportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, [unrelatedReportKey]: {reportID: unrelatedReportID, type: CONST.REPORT.TYPE.CHAT}}, {[reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}}, ]; const currentValue: VisibleReportActionsDerivedValue = { @@ -62,15 +64,20 @@ describe('visibleReportActions', () => { [unrelatedReportID]: {[unrelatedActionID]: true}, }; - // When the replacement report NVP triggers a recompute + // When the replacement and an unrelated report NVP trigger a recompute const result = visibleReportActionsConfig.compute(args, { currentValue, - sourceValues: {[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: {[reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}}}, + sourceValues: { + [ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: { + [reportNameValuePairsKey]: {reopenedAsReportID: 'replacementReportID'}, + [unrelatedReportNameValuePairsKey]: {agentZeroProcessingRequestIndicator: 'processing'}, + }, + }, triggeredKeys: new Set([ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]), }); - // Then the shared visibility state hides the stale survey + // Then the shared visibility state hides the stale survey without recomputing unrelated reports expect(result[reportID]?.[surveyActionID]).toBe(false); - expect(result[unrelatedReportID]?.[unrelatedActionID]).toBe(true); + expect(result[unrelatedReportID]).toBe(currentValue[unrelatedReportID]); }); }); From 0e420f0cfca6ebbd4fe36706e603f34db4031861 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 14 Oct 2026 07:42:02 +0200 Subject: [PATCH 624/781] fix: address review on wide pre-mount reveal --- .../GetStateForActionHandlers.ts | 101 +++++++++--------- .../handleReplaceFullscreenUnderRHP.test.ts | 34 ++++++ 2 files changed, 85 insertions(+), 50 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts index dc0bedae4209..b9f2385d3be7 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -122,7 +122,7 @@ type TabNavigatorPushPayloadParams = { }; type TabRouteForReplacement = NavigationState['routes'][number] | NavigationPartialRoute; -type TabStateForReplacement = Omit & {routes: TabRouteForReplacement[]; stale?: true | false}; +type TabStateForReplacement = Omit & {routes: TabRouteForReplacement[]; stale?: true | false; preloadedRouteKeys?: string[]}; type StaleTabStateOverrides = {routes: TabRouteForReplacement[]; index: number; routeNames?: string[]}; function toStaleTabState(existingTabState: NavigationState | PartialState | undefined, overrides: StaleTabStateOverrides): TabStateForReplacement { @@ -496,6 +496,52 @@ function withFocusedTabInHistory(tabState: TabStateForReplacement): TabStateForR return {...tabState, history: [...tabState.history, {type: 'route', key: focusedRoute.key, params: focusedRoute.params}]}; } +/** + * Puts the wide pre-mounted destination screen in place of the freshly built one, so the reveal shows the mounted instance. + * Everything else comes from the regular replace, so the stack, tab history and back navigation end up the same as without it. + */ +function withPreMountedDestination(builtTabState: TabStateForReplacement, existingTabState: NavigationState | undefined, preMountedRouteKey: string): TabStateForReplacement { + const targetTabRoute = builtTabState.routes[builtTabState.index]; + const existingTargetTabRoute = existingTabState?.routes.find((route) => route.name === targetTabRoute?.name); + const existingNestedState = isNavigationStateWithRoutes(existingTargetTabRoute?.state) ? existingTargetTabRoute.state : undefined; + const preMountedRoute = existingNestedState?.routes.find((route) => route.key === preMountedRouteKey); + const builtNestedState = isNavigationStateWithRoutes(targetTabRoute?.state) ? targetTabRoute.state : undefined; + const builtDestination = builtNestedState?.routes.at(-1); + if (!targetTabRoute || !preMountedRoute || !builtNestedState || builtDestination?.name !== preMountedRoute.name) { + Log.hmmm('[Navigation] Wide pre-mount does not match the revealed destination, replacing without it', {preMountedRouteKey}); + return withoutFocusedTabPreload(builtTabState); + } + + // Native keeps the focused report's history, which holds the pre-mount under it, so that copy is dropped to keep keys unique. + const backRoutes = builtNestedState.routes.slice(0, -1).filter((route) => !('key' in route) || route.key !== preMountedRouteKey); + const nestedRoutes = [...backRoutes, {...builtDestination, key: preMountedRouteKey}]; + // When every screen of the built stack is already mounted, the mounted stack state is kept with only its routes changed. + // A partial state would be rehydrated under a new key, which makes the navigator re-render all of its screens. + const mountedNestedState = isRealizedNavigationState(existingTargetTabRoute?.state) ? existingTargetTabRoute.state : undefined; + // Params still come from the built routes, so both branches reveal the destination the action asked for. + const mountedRoutes = nestedRoutes.map((route) => { + const existing = mountedNestedState?.routes.find((mountedRoute) => 'key' in route && mountedRoute.key === route.key); + return existing ? {...existing, params: route.params} : undefined; + }); + const routes = [...builtTabState.routes]; + if (mountedNestedState && mountedRoutes.every((route): route is NonNullable => !!route) && 'key' in targetTabRoute && typeof targetTabRoute.key === 'string') { + // Route snapshots added by addPushParamsRouterExtension are rebuilt from the routes, the same as a rehydrated stack gets them. + const history = mountedNestedState.history ? {history: mountedRoutes.map((route) => ({...route}))} : {}; + routes[builtTabState.index] = {...targetTabRoute, key: targetTabRoute.key, state: {...mountedNestedState, routes: mountedRoutes, index: mountedRoutes.length - 1, ...history}}; + } else { + routes[builtTabState.index] = {...targetTabRoute, state: {...builtNestedState, routes: nestedRoutes, index: nestedRoutes.length - 1}}; + } + return withoutFocusedTabPreload({...builtTabState, routes}); +} + +/** The pre-mount kept a covered tab unfrozen. It is focused from here, so it no longer needs that. */ +function withoutFocusedTabPreload(tabState: TabStateForReplacement): TabStateForReplacement { + const {preloadedRouteKeys} = tabState; + const focusedTabRoute = tabState.routes[tabState.index]; + const focusedTabRouteKey = focusedTabRoute && 'key' in focusedTabRoute ? focusedTabRoute.key : undefined; + return {...tabState, ...(preloadedRouteKeys ? {preloadedRouteKeys: preloadedRouteKeys.filter((key) => key !== focusedTabRouteKey)} : {})}; +} + /** * Handles the REPLACE_FULLSCREEN_UNDER_RHP action. * @@ -571,15 +617,15 @@ function handleReplaceFullscreenUnderRHP( const updatedTabState = preMountedRouteKey ? withPreMountedDestination(builtTabState, existingTabState, preMountedRouteKey) : builtTabState; // The remount guards against a push-transition flash on narrow layout (#90985). A wide pre-mount reveal keeps the key, // because a remount would drop the pre-mounted screen. Wide renders split screens without a push animation. - let staleTabState = updatedTabState; + let tabStateForReplace = updatedTabState; if (preMountedRouteKey && !getIsNarrowLayout()) { - staleTabState = withFocusedTabInHistory(updatedTabState); + tabStateForReplace = withFocusedTabInHistory(updatedTabState); } else if (existingTabState) { - staleTabState = markFocusedTabRouteForRemount(updatedTabState, existingTabState); + tabStateForReplace = markFocusedTabRouteForRemount(updatedTabState, existingTabState); } // Drop consumed deep-link hints before remounting, or React Navigation can replay the old target over the new state. - const updatedTabRoute = {...withSanitizedDeepLinkParams(existingTabRoute, undefined), state: staleTabState} as StackNavigationState['routes'][number]; + const updatedTabRoute = {...withSanitizedDeepLinkParams(existingTabRoute, undefined), state: tabStateForReplace} as StackNavigationState['routes'][number]; // Save original route so handleRemoveFullscreenUnderRHP can fully restore it on cancel. // In the cold-start fallback the tab navigator has no nested state yet, so saving the raw // route would leave it stateless and the dismiss-restore path (removePreInsertedFullscreenIfNeeded) @@ -840,51 +886,6 @@ function removeStalePreMountsFromResetAction(action: RootStackNavigatorAction): return payload === action.payload ? action : ({...action, payload} as RootStackNavigatorAction); } -/** - * Puts the wide pre-mounted destination screen in place of the freshly built one, so the reveal shows the mounted instance. - * Everything else comes from the regular replace, so the stack, tab history and back navigation end up the same as without it. - */ -function withPreMountedDestination(builtTabState: TabStateForReplacement, existingTabState: NavigationState | undefined, preMountedRouteKey: string): TabStateForReplacement { - const targetTabRoute = builtTabState.routes[builtTabState.index]; - const existingTargetTabRoute = existingTabState?.routes.find((route) => route.name === targetTabRoute?.name); - const existingNestedState = isNavigationStateWithRoutes(existingTargetTabRoute?.state) ? existingTargetTabRoute.state : undefined; - const preMountedRoute = existingNestedState?.routes.find((route) => route.key === preMountedRouteKey); - const builtNestedState = isNavigationStateWithRoutes(targetTabRoute?.state) ? targetTabRoute.state : undefined; - const builtDestination = builtNestedState?.routes.at(-1); - if (!targetTabRoute || !preMountedRoute || !builtNestedState || builtDestination?.name !== preMountedRoute.name) { - Log.hmmm('[Navigation] Wide pre-mount does not match the revealed destination, replacing without it', {preMountedRouteKey}); - return withoutFocusedTabPreload(builtTabState); - } - - // Native keeps the focused report's history, which holds the pre-mount under it, so that copy is dropped to keep keys unique. - const backRoutes = builtNestedState.routes.slice(0, -1).filter((route) => !('key' in route) || route.key !== preMountedRouteKey); - const nestedRoutes = [...backRoutes, {...builtDestination, key: preMountedRouteKey}]; - // When every screen of the built stack is already mounted, the mounted stack state is kept with only its routes changed. - // A partial state would be rehydrated under a new key, which makes the navigator re-render all of its screens. - const mountedNestedState = isRealizedNavigationState(existingTargetTabRoute?.state) ? existingTargetTabRoute.state : undefined; - // Params still come from the built routes, so both branches reveal the destination the action asked for. - const mountedRoutes = mountedNestedState - ? nestedRoutes.flatMap((route) => - mountedNestedState.routes.filter((existing) => 'key' in route && existing.key === route.key).map((existing) => ({...existing, params: route.params})), - ) - : []; - const routes = [...builtTabState.routes]; - if (mountedNestedState && mountedRoutes.length === nestedRoutes.length && 'key' in targetTabRoute && typeof targetTabRoute.key === 'string') { - routes[builtTabState.index] = {...targetTabRoute, key: targetTabRoute.key, state: {...mountedNestedState, routes: mountedRoutes, index: mountedRoutes.length - 1}}; - } else { - routes[builtTabState.index] = {...targetTabRoute, state: {...builtNestedState, routes: nestedRoutes, index: nestedRoutes.length - 1}}; - } - return withoutFocusedTabPreload({...builtTabState, routes}); -} - -/** The pre-mount kept a covered tab unfrozen. It is focused from here, so it no longer needs that. */ -function withoutFocusedTabPreload(tabState: TabStateForReplacement): TabStateForReplacement { - const {preloadedRouteKeys} = tabState as TabStateForReplacement & {preloadedRouteKeys?: string[]}; - const focusedTabRoute = tabState.routes[tabState.index]; - const focusedTabRouteKey = focusedTabRoute && 'key' in focusedTabRoute ? focusedTabRoute.key : undefined; - return {...tabState, ...(preloadedRouteKeys ? {preloadedRouteKeys: preloadedRouteKeys.filter((key) => key !== focusedTabRouteKey)} : {})}; -} - export { handleDismissModalAction, handleNavigatingToModalFromModal, diff --git a/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts b/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts index 5969927230d6..ea326074fc21 100644 --- a/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts +++ b/tests/unit/Navigation/createRootStackNavigator/handleReplaceFullscreenUnderRHP.test.ts @@ -425,6 +425,40 @@ describe('handleReplaceFullscreenUnderRHP — focused Reports stack preservation expect(getReportsSplitState(result)?.routes.at(-1)?.params).toEqual({reportID: 'B'}); }); + it('rebuilds the route snapshot history of the mounted stack from the revealed routes', () => { + // Given a wide layout with a mounted stack whose route snapshot history still holds report A and an older params snapshot + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockStubbedParsedState = makeReportsParsedState('B'); + const state = makeExistingReportsState( + [ + makeRoute(SCREENS.INBOX, undefined, undefined, 'inbox-key'), + makeRoute(SCREENS.REPORT, {reportID: 'B', reportActionID: 'old-action'}, undefined, 'Report-wide-pre-mount-7'), + makeRoute(SCREENS.REPORT, {reportID: 'A'}, undefined, 'report-a-key'), + ], + 2, + ); + const reportsSplitState = getReportsSplitState(state); + if (reportsSplitState) { + Object.assign(reportsSplitState, { + stale: false, + key: 'reports-split-state-key', + history: [ + {key: 'inbox-key', name: SCREENS.INBOX}, + {key: 'Report-wide-pre-mount-7', name: SCREENS.REPORT, params: {reportID: 'B', reportActionID: 'old-action'}}, + {key: 'report-a-key', name: SCREENS.REPORT, params: {reportID: 'A'}}, + ], + }); + } + const action: ReplaceFullscreenUnderRHPActionType = {...makeReportsAction('B'), payload: {...makeReportsAction('B').payload, preMountedRouteKey: 'Report-wide-pre-mount-7'}}; + + // When report B is revealed under the RHP + const result = handleReplaceFullscreenUnderRHP(state, action, CONFIG_OPTIONS, stackRouter); + + // Then the history matches the revealed routes one to one, like a rehydrated stack, so back cannot land on the dropped report A + const resultHistory: unknown[] = getReportsSplitState(result)?.history ?? []; + expect(resultHistory).toEqual([expect.objectContaining({key: 'inbox-key'}), expect.objectContaining({key: 'Report-wide-pre-mount-7', params: {reportID: 'B'}})]); + }); + it('drops the pre-mount kept in the preserved native history, so the revealed destination is its only copy', () => { // Given a wide native layout with the Reports tab focused on report A and report B pre-mounted under it mockGetPlatform.mockReturnValue(CONST.PLATFORM.IOS); From 7f23a3bd67b83c475292127f173a9ed9c6842f5d Mon Sep 17 00:00:00 2001 From: "nkdengineer (via MelvinBot)" Date: Wed, 14 Oct 2026 05:46:49 +0000 Subject: [PATCH 625/781] Group moved PolicyUtils tests by module and cover the settings error and pending helpers Wrap the tests for helpers moved into rules.ts, features.ts, workflows.ts, and errors.ts in matching describe blocks, and add tests for areSettingsInErrorFields and settingsPendingAction. Co-authored-by: nkdengineer --- tests/unit/PolicyUtilsTest.ts | 1510 +++++++++++++++++---------------- 1 file changed, 794 insertions(+), 716 deletions(-) diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 081dce3dacf4..b594cc4e348b 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -9,6 +9,7 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import { arePolicyRulesEnabled, + areSettingsInErrorFields, canEditWorkspaceSettings, canMemberAssignRole, canMemberManageMemberWithRole, @@ -115,6 +116,7 @@ import { isTaxCodeCustomized, isXeroActiveMatchingSource, isXeroVendorMatchingActive, + settingsPendingAction, shouldHideDynamicExternalWorkflowPeople, shouldShowPolicy, sortPoliciesByName, @@ -3222,148 +3224,40 @@ describe('PolicyUtils', () => { }); }); - describe('getPolicyBrickRoadIndicatorStatus', () => { - const baseAdminPolicy: OnyxEntry = createMock>({ - id: 'ABC123', - name: 'Test Workspace', - type: CONST.POLICY.TYPE.TEAM, - role: CONST.POLICY.ROLE.ADMIN, - employeeList: {}, - connections: {}, - errors: {}, - errorFields: {}, - }); - - const baseUserPolicy: OnyxEntry = createMock>({ - id: 'DEF456', - name: 'User Workspace', - type: CONST.POLICY.TYPE.TEAM, - role: CONST.POLICY.ROLE.USER, - employeeList: {}, - connections: {}, - errors: {}, - errorFields: {}, - }); - - it('does return an ERROR RBR when a sync error exists for an admin', () => { - const policyWithConnectionFailures = createMock>({ - ...baseAdminPolicy, - // Failed sync - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { - verified: false, - lastSync: { - errorDate: new Date().toISOString(), - errorMessage: 'Error', - isAuthenticationError: true, - isConnected: false, - isSuccessful: false, - source: 'NEWEXPENSIFY', - successfulDate: '', - }, - }, - }), - }); - - const result = getPolicyBrickRoadIndicatorStatus(policyWithConnectionFailures, false); - expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - }); - - it('does not return an ERROR RBR when a sync error exists for a user', () => { - const policyWithConnectionFailures = createMock>({ - ...baseUserPolicy, - // Failed sync - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { - verified: false, - lastSync: { - errorDate: new Date().toISOString(), - errorMessage: 'Error', - isAuthenticationError: true, - isConnected: false, - isSuccessful: false, - source: 'NEWEXPENSIFY', - successfulDate: '', - }, - }, - }), - }); - - const result = getPolicyBrickRoadIndicatorStatus(policyWithConnectionFailures, false); - expect(result).toBeUndefined(); - }); - - it('does not return an ERROR RBR when no sync errors exist for an admin', () => { - const policyWithoutConnections = createMock>({ - ...baseAdminPolicy, - connections: createMock({}), - }); - - const policyWithoutConnectionFailures = createMock>({ - ...baseAdminPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { - verified: true, - lastSync: { - errorDate: '', - errorMessage: '', - isAuthenticationError: false, - isConnected: true, - isSuccessful: true, - source: 'NEWEXPENSIFY', - successfulDate: '', - }, - }, - }), - }); - - const result = getPolicyBrickRoadIndicatorStatus(policyWithoutConnectionFailures, false); - expect(result).toBeUndefined(); - - const result2 = getPolicyBrickRoadIndicatorStatus(policyWithoutConnections, false); - expect(result2).toBeUndefined(); - }); - - it('does not return an ERROR RBR when no sync error exists for a user', () => { - const policyWithoutConnections = createMock>({ - ...baseUserPolicy, - connections: createMock({}), + describe('errors', () => { + describe('getPolicyBrickRoadIndicatorStatus', () => { + const baseAdminPolicy: OnyxEntry = createMock>({ + id: 'ABC123', + name: 'Test Workspace', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: {}, + connections: {}, + errors: {}, + errorFields: {}, }); - const policyWithoutConnectionFailures = createMock>({ - ...baseUserPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { - verified: true, - lastSync: { - errorDate: '', - errorMessage: '', - isAuthenticationError: false, - isConnected: true, - isSuccessful: true, - source: 'NEWEXPENSIFY', - successfulDate: '', - }, - }, - }), + const baseUserPolicy: OnyxEntry = createMock>({ + id: 'DEF456', + name: 'User Workspace', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + employeeList: {}, + connections: {}, + errors: {}, + errorFields: {}, }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithoutConnections, false); - expect(result).toBeUndefined(); - - const result2 = getPolicyBrickRoadIndicatorStatus(policyWithoutConnectionFailures, false); - expect(result2).toBeUndefined(); - }); - - describe('QBO Export Errors', () => { - it('does return an ERROR RBR when a QBO sync error exists for an admin', () => { - const policyWithQBOSyncError = createMock>({ + it('does return an ERROR RBR when a sync error exists for an admin', () => { + const policyWithConnectionFailures = createMock>({ ...baseAdminPolicy, + // Failed sync connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + verified: false, lastSync: { errorDate: new Date().toISOString(), - errorMessage: 'QBO sync failed', + errorMessage: 'Error', isAuthenticationError: true, isConnected: false, isSuccessful: false, @@ -3374,18 +3268,20 @@ describe('PolicyUtils', () => { }), }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, false); + const result = getPolicyBrickRoadIndicatorStatus(policyWithConnectionFailures, false); expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - it('does not return an ERROR RBR when a QBO sync error exists for a user', () => { - const policyWithQBOSyncError = createMock>({ + it('does not return an ERROR RBR when a sync error exists for a user', () => { + const policyWithConnectionFailures = createMock>({ ...baseUserPolicy, + // Failed sync connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + verified: false, lastSync: { errorDate: new Date().toISOString(), - errorMessage: 'QBO sync failed', + errorMessage: 'Error', isAuthenticationError: true, isConnected: false, isSuccessful: false, @@ -3396,15 +3292,21 @@ describe('PolicyUtils', () => { }), }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, false); + const result = getPolicyBrickRoadIndicatorStatus(policyWithConnectionFailures, false); expect(result).toBeUndefined(); }); - it('does not return an ERROR RBR when no QBO sync errors exist for an admin', () => { - const policyWithSuccessfulQBOSync = createMock>({ + it('does not return an ERROR RBR when no sync errors exist for an admin', () => { + const policyWithoutConnections = createMock>({ + ...baseAdminPolicy, + connections: createMock({}), + }); + + const policyWithoutConnectionFailures = createMock>({ ...baseAdminPolicy, connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + verified: true, lastSync: { errorDate: '', errorMessage: '', @@ -3412,27 +3314,36 @@ describe('PolicyUtils', () => { isConnected: true, isSuccessful: true, source: 'NEWEXPENSIFY', - successfulDate: new Date().toISOString(), + successfulDate: '', }, }, }), }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithSuccessfulQBOSync, false); + const result = getPolicyBrickRoadIndicatorStatus(policyWithoutConnectionFailures, false); expect(result).toBeUndefined(); + + const result2 = getPolicyBrickRoadIndicatorStatus(policyWithoutConnections, false); + expect(result2).toBeUndefined(); }); - it('does not return an ERROR RBR when QBO sync is in progress for an admin', () => { - const policyWithQBOSyncError = createMock>({ - ...baseAdminPolicy, + it('does not return an ERROR RBR when no sync error exists for a user', () => { + const policyWithoutConnections = createMock>({ + ...baseUserPolicy, + connections: createMock({}), + }); + + const policyWithoutConnectionFailures = createMock>({ + ...baseUserPolicy, connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: { + verified: true, lastSync: { - errorDate: new Date().toISOString(), - errorMessage: 'QBO sync failed', - isAuthenticationError: true, - isConnected: false, - isSuccessful: false, + errorDate: '', + errorMessage: '', + isAuthenticationError: false, + isConnected: true, + isSuccessful: true, source: 'NEWEXPENSIFY', successfulDate: '', }, @@ -3440,140 +3351,431 @@ describe('PolicyUtils', () => { }), }); - // When sync is in progress (second parameter is true), should not show error - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, true); + const result = getPolicyBrickRoadIndicatorStatus(policyWithoutConnections, false); expect(result).toBeUndefined(); + + const result2 = getPolicyBrickRoadIndicatorStatus(policyWithoutConnectionFailures, false); + expect(result2).toBeUndefined(); }); - it('does return an ERROR RBR when QBO reimbursable export destination account is missing for an admin', () => { - const policyWithMissingQBOAccount = createMock>({ - ...baseAdminPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: { - reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - reimbursableExpensesAccount: undefined, + describe('QBO Export Errors', () => { + it('does return an ERROR RBR when a QBO sync error exists for an admin', () => { + const policyWithQBOSyncError = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + errorDate: new Date().toISOString(), + errorMessage: 'QBO sync failed', + isAuthenticationError: true, + isConnected: false, + isSuccessful: false, + source: 'NEWEXPENSIFY', + successfulDate: '', + }, }, - }, - }), + }), + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, false); + expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithMissingQBOAccount, false); - expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - }); + it('does not return an ERROR RBR when a QBO sync error exists for a user', () => { + const policyWithQBOSyncError = createMock>({ + ...baseUserPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + errorDate: new Date().toISOString(), + errorMessage: 'QBO sync failed', + isAuthenticationError: true, + isConnected: false, + isSuccessful: false, + source: 'NEWEXPENSIFY', + successfulDate: '', + }, + }, + }), + }); - it('does not return an ERROR RBR when QBO reimbursable export destination account is missing for a user', () => { - const policyWithMissingQBOAccount = createMock>({ - ...baseUserPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: { - reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - reimbursableExpensesAccount: undefined, + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, false); + expect(result).toBeUndefined(); + }); + + it('does not return an ERROR RBR when no QBO sync errors exist for an admin', () => { + const policyWithSuccessfulQBOSync = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + errorDate: '', + errorMessage: '', + isAuthenticationError: false, + isConnected: true, + isSuccessful: true, + source: 'NEWEXPENSIFY', + successfulDate: new Date().toISOString(), + }, }, - }, - }), + }), + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithSuccessfulQBOSync, false); + expect(result).toBeUndefined(); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithMissingQBOAccount, false); - expect(result).toBeUndefined(); - }); + it('does not return an ERROR RBR when QBO sync is in progress for an admin', () => { + const policyWithQBOSyncError = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + lastSync: { + errorDate: new Date().toISOString(), + errorMessage: 'QBO sync failed', + isAuthenticationError: true, + isConnected: false, + isSuccessful: false, + source: 'NEWEXPENSIFY', + successfulDate: '', + }, + }, + }), + }); - it('does not return an ERROR RBR when QBO reimbursable export destination account is configured for an admin', () => { - const policyWithQBOConfigured = createMock>({ - ...baseAdminPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: { - reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - reimbursableExpensesAccount: {id: '123', name: 'Test Account'}, + // When sync is in progress (second parameter is true), should not show error + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOSyncError, true); + expect(result).toBeUndefined(); + }); + + it('does return an ERROR RBR when QBO reimbursable export destination account is missing for an admin', () => { + const policyWithMissingQBOAccount = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + reimbursableExpensesAccount: undefined, + }, }, - }, - }), + }), + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithMissingQBOAccount, false); + expect(result).toEqual(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOConfigured, false); - expect(result).toBeUndefined(); - }); + it('does not return an ERROR RBR when QBO reimbursable export destination account is missing for a user', () => { + const policyWithMissingQBOAccount = createMock>({ + ...baseUserPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + reimbursableExpensesAccount: undefined, + }, + }, + }), + }); - it('does not return an ERROR RBR when QBO connection does not exist for an admin', () => { - const policyWithoutQBOConnection = createMock>({ - ...baseAdminPolicy, - connections: {}, + const result = getPolicyBrickRoadIndicatorStatus(policyWithMissingQBOAccount, false); + expect(result).toBeUndefined(); }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithoutQBOConnection, false); - expect(result).toBeUndefined(); - }); + it('does not return an ERROR RBR when QBO reimbursable export destination account is configured for an admin', () => { + const policyWithQBOConfigured = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + reimbursableExpensesExportDestination: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + reimbursableExpensesAccount: {id: '123', name: 'Test Account'}, + }, + }, + }), + }); - it('does not return an ERROR RBR when QBO reimbursable export destination is not set for an admin', () => { - const policyWithQBONoExportDestination = createMock>({ - ...baseAdminPolicy, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: { - reimbursableExpensesExportDestination: undefined, - reimbursableExpensesAccount: undefined, + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBOConfigured, false); + expect(result).toBeUndefined(); + }); + + it('does not return an ERROR RBR when QBO connection does not exist for an admin', () => { + const policyWithoutQBOConnection = createMock>({ + ...baseAdminPolicy, + connections: {}, + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithoutQBOConnection, false); + expect(result).toBeUndefined(); + }); + + it('does not return an ERROR RBR when QBO reimbursable export destination is not set for an admin', () => { + const policyWithQBONoExportDestination = createMock>({ + ...baseAdminPolicy, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + reimbursableExpensesExportDestination: undefined, + reimbursableExpensesAccount: undefined, + }, }, - }, - }), + }), + }); + + const result = getPolicyBrickRoadIndicatorStatus(policyWithQBONoExportDestination, false); + expect(result).toBeUndefined(); }); + }); + }); - const result = getPolicyBrickRoadIndicatorStatus(policyWithQBONoExportDestination, false); - expect(result).toBeUndefined(); + describe('hasPolicyRulesError', () => { + // Whether a merchant rule failed is reduced by the caller's selector and covered in + // ExpenseDefaultRuleUtilsTest, so only the agent rules and the pass-through are checked here. + const POLICY_ID = 'policy-with-rules'; + + it('returns false for an undefined policy', () => { + expect(hasPolicyRulesError(undefined, false)).toBe(false); + }); + + it('returns false when no merchant or agent rules exist', () => { + const policy: Policy = {...createRandomPolicy(0), id: POLICY_ID, rules: {}}; + expect(hasPolicyRulesError(policy, false)).toBe(false); + }); + + it('returns false when agent rules exist but none have errors', () => { + const policy: Policy = { + ...createRandomPolicy(0), + id: POLICY_ID, + rules: {agentRules: {ai1: {ruleID: 'ai1', prompt: 'p', created: '2026-06-08'}}}, + }; + expect(hasPolicyRulesError(policy, false)).toBe(false); + }); + + it('returns true when a merchant rule has errors', () => { + const policy: Policy = {...createRandomPolicy(0), id: POLICY_ID, rules: {}}; + expect(hasPolicyRulesError(policy, true)).toBe(true); + }); + + it('returns true when an agent rule has errors', () => { + const policy: Policy = { + ...createRandomPolicy(0), + id: POLICY_ID, + rules: { + agentRules: {ai1: {ruleID: 'ai1', prompt: 'p', created: '2026-06-08', errors: {123: 'boom'}}}, + }, + }; + expect(hasPolicyRulesError(policy, false)).toBe(true); }); }); - }); - describe('hasDynamicExternalWorkflow', () => { - it('should return true when policy has DYNAMICEXTERNAL approval mode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, - }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(true); + describe('isQBORefreshTokenExpiringSoonSelector', () => { + const buildQBOPolicy = (role: string, refreshTokenExpiresAt: number): Policy => + Object.assign(createRandomPolicy(1), { + role, + connections: {quickbooksOnline: {config: {credentials: {companyID: '12345', refreshTokenExpiresAt}}, lastSync: {isAuthenticationError: false}}}, + }); + const expiringSoon = Math.floor(Date.now() / 1000) + 3 * 86400; + + it('returns true for an admin whose QBO refresh token expires within the warning window', () => { + expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.ADMIN, expiringSoon))).toBe(true); + }); + + it('returns false for a member, since only admins can reconnect', () => { + expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.USER, expiringSoon))).toBe(false); + }); + + it('returns false when the token is still far from expiring or the policy is undefined', () => { + expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.ADMIN, Math.floor(Date.now() / 1000) + 60 * 86400))).toBe(false); + expect(isQBORefreshTokenExpiringSoonSelector(undefined)).toBe(false); + }); }); - it('should return false when policy has BASIC approval mode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + describe('isMergeHRCompleteSetupNeededSelector', () => { + // Only the fields read by the selector are modeled here; `Object.assign` attaches the connection + // to a real policy without an unsafe cast. + type MergeHRTestConnection = { + lastSync?: {syncStatus?: string}; + data?: {groups?: unknown[]}; + config?: {groups?: unknown[] | null}; }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(false); + const buildMergeHRPolicy = (seed: number, mergeHR: MergeHRTestConnection): Policy => Object.assign(createRandomPolicy(seed), {connections: {merge_hris: mergeHR}}); + const mergeHRBase = { + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, + data: {groups: [{id: 'g1', name: 'Engineering'}]}, + }; + + it('returns false when policy is undefined', () => { + expect(isMergeHRCompleteSetupNeededSelector(undefined)).toBe(false); + }); + + it('returns false when policy has no merge_hris connection', () => { + const policy = createRandomPolicy(1); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); + }); + + it('returns false when sync is not done', () => { + const policy = buildMergeHRPolicy(2, {...mergeHRBase, lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.SYNCING}}); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); + }); + + it('returns false when sync is done but there are no groups', () => { + const policy = buildMergeHRPolicy(3, {...mergeHRBase, data: {groups: []}}); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); + }); + + it('returns false when setup is already complete (groups configured)', () => { + const policy = buildMergeHRPolicy(4, {...mergeHRBase, config: {groups: ['g1']}}); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); + }); + + it('returns true when sync is done, groups exist, and setup is not yet complete', () => { + const policy = buildMergeHRPolicy(5, mergeHRBase); + expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(true); + }); }); - it('should return false when policy has ADVANCED approval mode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(false); + describe('areSettingsInErrorFields', () => { + it('returns false when the settings or the error fields are undefined', () => { + // Given settings without error fields, and error fields without settings + const errorFields = {autoSync: {error: 'Sync failed'}}; + + // When checking either incomplete input + // Then there is nothing to match, so no error is reported + expect(areSettingsInErrorFields(undefined, errorFields)).toBe(false); + expect(areSettingsInErrorFields(['autoSync'], undefined)).toBe(false); + }); + + it('returns true when one of the settings has an error field', () => { + // Given error fields where only one of the requested settings has an error + const errorFields = {exporter: {error: 'Invalid exporter'}}; + + // When checking a list of settings that includes it + const result = areSettingsInErrorFields(['autoSync', 'exporter'], errorFields); + + // Then the error is reported because a single matching setting is enough + expect(result).toBe(true); + }); + + it('returns false when none of the settings has an error field', () => { + // Given error fields for a setting that was not requested + const errorFields = {exporter: {error: 'Invalid exporter'}}; + + // When checking unrelated settings + const result = areSettingsInErrorFields(['autoSync', 'syncReimbursedReports'], errorFields); + + // Then no error is reported for those settings + expect(result).toBe(false); + }); }); - it('should return false when policy has OPTIONAL approval mode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, - }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(false); + describe('settingsPendingAction', () => { + it('returns null when the settings or the pending fields are undefined', () => { + // Given settings without pending fields, and pending fields without settings + const pendingFields = {autoSync: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + + // When reading the pending action from either incomplete input + // Then null is returned to signal there is no pending action to show + expect(settingsPendingAction(undefined, pendingFields)).toBeNull(); + expect(settingsPendingAction(['autoSync'], undefined)).toBeNull(); + }); + + it('returns the pending action of the setting that has one', () => { + // Given pending fields where one of the requested settings is being updated + const pendingFields = {exporter: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + + // When reading the pending action for a list of settings that includes it + const result = settingsPendingAction(['autoSync', 'exporter'], pendingFields); + + // Then that setting's pending action is returned so the UI can show it as pending + expect(result).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + }); + + it('returns undefined when none of the settings has a pending field', () => { + // Given pending fields for a setting that was not requested + const pendingFields = {exporter: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + + // When reading the pending action for unrelated settings + const result = settingsPendingAction(['autoSync', 'syncReimbursedReports'], pendingFields); + + // Then no pending action is returned + expect(result).toBeUndefined(); + }); }); + }); - it('should return false when policy is undefined', () => { - const result = hasDynamicExternalWorkflow(undefined); - expect(result).toBe(false); + describe('workflows', () => { + describe('hasDynamicExternalWorkflow', () => { + it('should return true when policy has DYNAMICEXTERNAL approval mode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(true); + }); + + it('should return false when policy has BASIC approval mode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(false); + }); + + it('should return false when policy has ADVANCED approval mode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(false); + }); + + it('should return false when policy has OPTIONAL approval mode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(false); + }); + + it('should return false when policy is undefined', () => { + const result = hasDynamicExternalWorkflow(undefined); + expect(result).toBe(false); + }); + + it('should return false when policy has no approvalMode', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + approvalMode: undefined, + }; + const result = hasDynamicExternalWorkflow(policy); + expect(result).toBe(false); + }); }); - it('should return false when policy has no approvalMode', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - approvalMode: undefined, - }; - const result = hasDynamicExternalWorkflow(policy); - expect(result).toBe(false); + describe('shouldHideDynamicExternalWorkflowPeople', () => { + it('returns false when the policy is undefined', () => { + expect(shouldHideDynamicExternalWorkflowPeople(undefined)).toBe(false); + }); + + it('returns true for a Dynamic External Workflow policy with the flag set', () => { + const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, dynamicExternalWorkflowHidePeople: true}; + expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(true); + }); + + // The backend only returns the flag when it is `true`, so an absent flag is the normal DEW case. + it('returns false for a Dynamic External Workflow policy without the flag', () => { + const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL}; + expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(false); + }); + + it('returns false when a stale flag is left on a policy that no longer uses a Dynamic External Workflow', () => { + const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, dynamicExternalWorkflowHidePeople: true}; + expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(false); + }); }); }); @@ -3978,19 +4180,6 @@ describe('PolicyUtils', () => { }); }); - describe('getTagApproverRule', () => { - it('should return undefined when no approval rules are present', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - rules: { - approvalRules: [], - }, - }; - const result = getTagApproverRule(policy, ''); - expect(result).toBeUndefined(); - }); - }); - describe('tax', () => { describe('getAllTaxRatesNamesAndValues', () => { it('returns empty object when there are no policies or no tax rates', () => { @@ -4305,93 +4494,130 @@ describe('PolicyUtils', () => { }); }); - it('should return undefined when no tag approver rule is present', () => { - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - rules: { - approvalRules: [ - { - id: 'rule-1', - applyWhen: [ - { - field: CONST.POLICY.FIELDS.TAG, - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - value: 'Fake tag', - }, - ], - approver: 'lol@hhh.com', + describe('features', () => { + describe('getDefaultTimeTrackingRate', () => { + it('should return rate in subunits', () => { + const policy: Policy = { + ...createRandomPolicy(1), + units: { + time: { + enabled: true, + rate: 20, + }, }, - ], - }, - }; - const result = getTagApproverRule(policy, 'NonExistentTag'); - expect(result).toBeUndefined(); - }); + }; + expect(getDefaultTimeTrackingRate(policy)).toBe(2000); + }); - it('should return the tag approver rule when present', () => { - const tagName = 'ImportantTag'; - const policy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - rules: { - approvalRules: [ - { - id: 'rule-1', - applyWhen: [ - { - field: CONST.POLICY.FIELDS.TAG, - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - value: tagName, - }, - ], - approver: 'approver@example.com', + it('should return 0 when the rate is 0, not undefined', () => { + const policy: Policy = { + ...createRandomPolicy(1), + units: { + time: { + enabled: true, + rate: 0, + }, }, - ], - }, - }; - const result = getTagApproverRule(policy, tagName); - expect(result).toEqual({ - id: 'rule-1', - applyWhen: [ - { - field: CONST.POLICY.FIELDS.TAG, - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - value: tagName, - }, - ], - approver: 'approver@example.com', + }; + expect(getDefaultTimeTrackingRate(policy)).toBe(0); + }); + + it('should return undefined when the rate is not defined on the policy', () => { + const policy = createRandomPolicy(1); + expect(getDefaultTimeTrackingRate(policy)).toBeUndefined(); + }); }); - }); - describe('getDefaultTimeTrackingRate', () => { - it('should return rate in subunits', () => { - const policy: Policy = { - ...createRandomPolicy(1), - units: { - time: { - enabled: true, - rate: 20, - }, - }, - }; - expect(getDefaultTimeTrackingRate(policy)).toBe(2000); - }); + describe('isInvoiceFieldsEnabled', () => { + it('returns true for a control policy with areInvoiceFieldsEnabled explicitly true', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: true}; + expect(isInvoiceFieldsEnabled(policy)).toBe(true); + }); + + it('returns false for a control policy with areInvoiceFieldsEnabled explicitly false', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: false}; + expect(isInvoiceFieldsEnabled(policy)).toBe(false); + }); + + it('returns false for a collect policy even when areInvoiceFieldsEnabled is true', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), areInvoiceFieldsEnabled: true}; + expect(isInvoiceFieldsEnabled(policy)).toBe(false); + }); + + it('returns false for an undefined policy', () => { + expect(isInvoiceFieldsEnabled(undefined)).toBe(false); + }); + }); + + describe('isPolicyFeatureEnabled for ARE_INVOICE_FIELDS_ENABLED', () => { + it('returns true for a control policy with invoice fields enabled', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: true}; + expect(isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED)).toBe(true); + }); + + it('returns false for a collect policy even with areInvoiceFieldsEnabled true', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), areInvoiceFieldsEnabled: true}; + expect(isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED)).toBe(false); + }); + + it('returns false for a control policy with invoice fields disabled', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: false}; + expect(isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED)).toBe(false); + }); + }); + + describe('arePolicyRulesEnabled', () => { + const corporateBase = createMock({id: 'policy1', type: CONST.POLICY.TYPE.CORPORATE}); + const teamBase = createMock({id: 'policy2', type: CONST.POLICY.TYPE.TEAM}); + + it('returns true for a corporate policy with areRulesEnabled explicitly true', () => { + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: true})).toBe(true); + }); + + it('returns false for a corporate policy with areRulesEnabled explicitly false', () => { + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: false})).toBe(false); + }); + + it('returns false for a corporate policy with areRulesEnabled undefined and no categories', () => { + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined})).toBe(false); + }); + + it('returns false for a corporate policy with areRulesEnabled undefined and categories with no rule fields', () => { + const categories = {Advertising: {name: 'Advertising', enabled: true, 'GL Code': '1234'}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(false); + }); + + it('returns true for a corporate policy with areRulesEnabled undefined and a category with maxExpenseAmount set', () => { + const categories = {Advertising: {name: 'Advertising', enabled: true, maxExpenseAmount: 50000}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); + }); - it('should return 0 when the rate is 0, not undefined', () => { - const policy: Policy = { - ...createRandomPolicy(1), - units: { - time: { - enabled: true, - rate: 0, - }, - }, - }; - expect(getDefaultTimeTrackingRate(policy)).toBe(0); - }); + it('returns true for a corporate policy with areRulesEnabled undefined and a category with maxAmountNoReceipt = 0', () => { + const categories = {Travel: {name: 'Travel', enabled: true, maxAmountNoReceipt: 0}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); + }); + + it('returns true for a corporate policy with areRulesEnabled undefined and a category with areCommentsRequired = true', () => { + const categories = {Meals: {name: 'Meals', enabled: true, areCommentsRequired: true}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); + }); + + it('returns true for a corporate policy with areRulesEnabled undefined and a category with commentHint set', () => { + const categories = {Car: {name: 'Car', enabled: true, commentHint: 'Enter purpose'}}; + expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); + }); + + it('returns false for a team policy with areRulesEnabled undefined', () => { + expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: undefined})).toBe(false); + }); + + it('returns true for a team policy with areRulesEnabled explicitly true', () => { + expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: true}, undefined)).toBe(true); + }); - it('should return undefined when the rate is not defined on the policy', () => { - const policy = createRandomPolicy(1); - expect(getDefaultTimeTrackingRate(policy)).toBeUndefined(); + it('returns false for a team policy with areRulesEnabled explicitly false', () => { + expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: false})).toBe(false); + }); }); }); @@ -4506,44 +4732,6 @@ describe('PolicyUtils', () => { expect(getActivePoliciesWithExpenseChatAndPerDiemEnabled(policies, undefined)).toHaveLength(0); }); - - describe('isInvoiceFieldsEnabled', () => { - it('returns true for a control policy with areInvoiceFieldsEnabled explicitly true', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: true}; - expect(isInvoiceFieldsEnabled(policy)).toBe(true); - }); - - it('returns false for a control policy with areInvoiceFieldsEnabled explicitly false', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: false}; - expect(isInvoiceFieldsEnabled(policy)).toBe(false); - }); - - it('returns false for a collect policy even when areInvoiceFieldsEnabled is true', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), areInvoiceFieldsEnabled: true}; - expect(isInvoiceFieldsEnabled(policy)).toBe(false); - }); - - it('returns false for an undefined policy', () => { - expect(isInvoiceFieldsEnabled(undefined)).toBe(false); - }); - }); - - describe('isPolicyFeatureEnabled for ARE_INVOICE_FIELDS_ENABLED', () => { - it('returns true for a control policy with invoice fields enabled', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: true}; - expect(isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED)).toBe(true); - }); - - it('returns false for a collect policy even with areInvoiceFieldsEnabled true', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), areInvoiceFieldsEnabled: true}; - expect(isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED)).toBe(false); - }); - - it('returns false for a control policy with invoice fields disabled', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: false}; - expect(isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED)).toBe(false); - }); - }); }); describe('sortPoliciesByName', () => { @@ -4595,199 +4783,289 @@ describe('PolicyUtils', () => { }); }); - describe('hasConfiguredRules', () => { - it('returns false when policy is undefined', () => { - expect(hasConfiguredRules(undefined, undefined, false)).toBe(false); - }); - - it('returns false when policy has no rules configured', () => { - expect(hasConfiguredRules(createMock({}), undefined, false)).toBe(false); - }); - - describe('customRules', () => { - it('returns true when customRules is non-empty', () => { - expect(hasConfiguredRules(createMock({customRules: 'some rule'}), undefined, false)).toBe(true); - }); - - it('returns false when customRules is an empty string', () => { - expect(hasConfiguredRules(createMock({customRules: ''}), undefined, false)).toBe(false); - }); - - it('returns false when customRules is only whitespace', () => { - expect(hasConfiguredRules(createMock({customRules: ' '}), undefined, false)).toBe(false); - }); - }); - - describe('rules.approvalRules', () => { - it('returns true when approvalRules has items', () => { - const policy = createMock({rules: {approvalRules: [{id: '1', applyWhen: [], approver: 'approver@test.com'}]}}); - expect(hasConfiguredRules(policy, undefined, false)).toBe(true); + describe('rules', () => { + describe('getTagApproverRule', () => { + it('should return undefined when no approval rules are present', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + rules: { + approvalRules: [], + }, + }; + const result = getTagApproverRule(policy, ''); + expect(result).toBeUndefined(); }); - it('returns false when approvalRules is empty', () => { - expect(hasConfiguredRules(createMock({rules: {approvalRules: []}}), undefined, false)).toBe(false); + it('should return undefined when no tag approver rule is present', () => { + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + rules: { + approvalRules: [ + { + id: 'rule-1', + applyWhen: [ + { + field: CONST.POLICY.FIELDS.TAG, + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + value: 'Fake tag', + }, + ], + approver: 'lol@hhh.com', + }, + ], + }, + }; + const result = getTagApproverRule(policy, 'NonExistentTag'); + expect(result).toBeUndefined(); }); - }); - describe('rules.expenseRules', () => { - it('returns true when expenseRules has items', () => { - const policy = createMock({ + it('should return the tag approver rule when present', () => { + const tagName = 'ImportantTag'; + const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), rules: { - expenseRules: [ + approvalRules: [ { - id: '1', - applyWhen: [], - tax: {field_id_TAX: {externalID: 'TAX_US'}}, + id: 'rule-1', + applyWhen: [ + { + field: CONST.POLICY.FIELDS.TAG, + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + value: tagName, + }, + ], + approver: 'approver@example.com', }, ], }, + }; + const result = getTagApproverRule(policy, tagName); + expect(result).toEqual({ + id: 'rule-1', + applyWhen: [ + { + field: CONST.POLICY.FIELDS.TAG, + condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, + value: tagName, + }, + ], + approver: 'approver@example.com', }); - expect(hasConfiguredRules(policy, undefined, false)).toBe(true); - }); - - it('returns false when expenseRules is empty', () => { - expect(hasConfiguredRules(createMock({rules: {expenseRules: []}}), undefined, false)).toBe(false); }); }); - describe('merchant rules', () => { - // Which rules count towards this is decided by the caller's selector and covered in - // ExpenseDefaultRuleUtilsTest, so only the pass-through is checked here. - const policy = createMock({id: 'policy1', rules: {}}); - - it('returns true when the policy has a merchant rule', () => { - expect(hasConfiguredRules(policy, undefined, true)).toBe(true); + describe('hasConfiguredRules', () => { + it('returns false when policy is undefined', () => { + expect(hasConfiguredRules(undefined, undefined, false)).toBe(false); }); - it('returns false when it has none', () => { - expect(hasConfiguredRules(policy, undefined, false)).toBe(false); + it('returns false when policy has no rules configured', () => { + expect(hasConfiguredRules(createMock({}), undefined, false)).toBe(false); }); - }); - describe('maxExpenseAmount', () => { - it('returns true when maxExpenseAmount is set to a non-default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmount: 500000}), undefined, false)).toBe(true); - }); + describe('customRules', () => { + it('returns true when customRules is non-empty', () => { + expect(hasConfiguredRules(createMock({customRules: 'some rule'}), undefined, false)).toBe(true); + }); - it('returns false when maxExpenseAmount is the default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT}), undefined, false)).toBe(false); - }); + it('returns false when customRules is an empty string', () => { + expect(hasConfiguredRules(createMock({customRules: ''}), undefined, false)).toBe(false); + }); - it('returns false when maxExpenseAmount is the disabled value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmount: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + it('returns false when customRules is only whitespace', () => { + expect(hasConfiguredRules(createMock({customRules: ' '}), undefined, false)).toBe(false); + }); }); - }); - describe('maxExpenseAge', () => { - it('returns true when maxExpenseAge is set to a non-default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAge: 30}), undefined, false)).toBe(true); - }); + describe('rules.approvalRules', () => { + it('returns true when approvalRules has items', () => { + const policy = createMock({rules: {approvalRules: [{id: '1', applyWhen: [], approver: 'approver@test.com'}]}}); + expect(hasConfiguredRules(policy, undefined, false)).toBe(true); + }); - it('returns false when maxExpenseAge is the default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAge: CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE}), undefined, false)).toBe(false); + it('returns false when approvalRules is empty', () => { + expect(hasConfiguredRules(createMock({rules: {approvalRules: []}}), undefined, false)).toBe(false); + }); }); - it('returns false when maxExpenseAge is the disabled value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAge: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); - }); - }); + describe('rules.expenseRules', () => { + it('returns true when expenseRules has items', () => { + const policy = createMock({ + rules: { + expenseRules: [ + { + id: '1', + applyWhen: [], + tax: {field_id_TAX: {externalID: 'TAX_US'}}, + }, + ], + }, + }); + expect(hasConfiguredRules(policy, undefined, false)).toBe(true); + }); - describe('maxExpenseAmountNoReceipt', () => { - it('returns true when maxExpenseAmountNoReceipt is set to a non-default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: 5000}), undefined, false)).toBe(true); + it('returns false when expenseRules is empty', () => { + expect(hasConfiguredRules(createMock({rules: {expenseRules: []}}), undefined, false)).toBe(false); + }); }); - it('returns false when maxExpenseAmountNoReceipt is the default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT}), undefined, false)).toBe(false); - }); + describe('merchant rules', () => { + // Which rules count towards this is decided by the caller's selector and covered in + // ExpenseDefaultRuleUtilsTest, so only the pass-through is checked here. + const policy = createMock({id: 'policy1', rules: {}}); + + it('returns true when the policy has a merchant rule', () => { + expect(hasConfiguredRules(policy, undefined, true)).toBe(true); + }); - it('returns false when maxExpenseAmountNoReceipt is the disabled value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + it('returns false when it has none', () => { + expect(hasConfiguredRules(policy, undefined, false)).toBe(false); + }); }); - }); - describe('maxExpenseAmountNoItemizedReceipt', () => { - it('returns true when maxExpenseAmountNoItemizedReceipt is set to a non-default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: 10000}), undefined, false)).toBe(true); + describe('maxExpenseAmount', () => { + it('returns true when maxExpenseAmount is set to a non-default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmount: 500000}), undefined, false)).toBe(true); + }); + + it('returns false when maxExpenseAmount is the default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT}), undefined, false)).toBe(false); + }); + + it('returns false when maxExpenseAmount is the disabled value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmount: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + }); }); - it('returns false when maxExpenseAmountNoItemizedReceipt is the default value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT}), undefined, false)).toBe(false); + describe('maxExpenseAge', () => { + it('returns true when maxExpenseAge is set to a non-default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAge: 30}), undefined, false)).toBe(true); + }); + + it('returns false when maxExpenseAge is the default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAge: CONST.POLICY.DEFAULT_MAX_EXPENSE_AGE}), undefined, false)).toBe(false); + }); + + it('returns false when maxExpenseAge is the disabled value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAge: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + }); }); - it('returns false when maxExpenseAmountNoItemizedReceipt is the disabled value', () => { - expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + describe('maxExpenseAmountNoReceipt', () => { + it('returns true when maxExpenseAmountNoReceipt is set to a non-default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: 5000}), undefined, false)).toBe(true); + }); + + it('returns false when maxExpenseAmountNoReceipt is the default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT}), undefined, false)).toBe(false); + }); + + it('returns false when maxExpenseAmountNoReceipt is the disabled value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + }); }); - }); - describe('defaultBillable', () => { - it('returns true when defaultBillable is true', () => { - expect(hasConfiguredRules(createMock({defaultBillable: true}), undefined, false)).toBe(true); + describe('maxExpenseAmountNoItemizedReceipt', () => { + it('returns true when maxExpenseAmountNoItemizedReceipt is set to a non-default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: 10000}), undefined, false)).toBe(true); + }); + + it('returns false when maxExpenseAmountNoItemizedReceipt is the default value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT}), undefined, false)).toBe(false); + }); + + it('returns false when maxExpenseAmountNoItemizedReceipt is the disabled value', () => { + expect(hasConfiguredRules(createMock({maxExpenseAmountNoItemizedReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE}), undefined, false)).toBe(false); + }); }); - it('returns false when defaultBillable is false', () => { - expect(hasConfiguredRules(createMock({defaultBillable: false}), undefined, false)).toBe(false); + describe('defaultBillable', () => { + it('returns true when defaultBillable is true', () => { + expect(hasConfiguredRules(createMock({defaultBillable: true}), undefined, false)).toBe(true); + }); + + it('returns false when defaultBillable is false', () => { + expect(hasConfiguredRules(createMock({defaultBillable: false}), undefined, false)).toBe(false); + }); }); - }); - describe('defaultReimbursable', () => { - it('returns true when defaultReimbursable is false', () => { - expect(hasConfiguredRules(createMock({defaultReimbursable: false}), undefined, false)).toBe(true); + describe('defaultReimbursable', () => { + it('returns true when defaultReimbursable is false', () => { + expect(hasConfiguredRules(createMock({defaultReimbursable: false}), undefined, false)).toBe(true); + }); + + it('returns false when defaultReimbursable is true', () => { + expect(hasConfiguredRules(createMock({defaultReimbursable: true}), undefined, false)).toBe(false); + }); }); - it('returns false when defaultReimbursable is true', () => { - expect(hasConfiguredRules(createMock({defaultReimbursable: true}), undefined, false)).toBe(false); + describe('eReceipts', () => { + it('returns true when eReceipts is true', () => { + expect(hasConfiguredRules(createMock({eReceipts: true}), undefined, false)).toBe(true); + }); + + it('returns false when eReceipts is false', () => { + expect(hasConfiguredRules(createMock({eReceipts: false}), undefined, false)).toBe(false); + }); }); - }); - describe('eReceipts', () => { - it('returns true when eReceipts is true', () => { - expect(hasConfiguredRules(createMock({eReceipts: true}), undefined, false)).toBe(true); + describe('requireCompanyCardsEnabled', () => { + it('returns true when requireCompanyCardsEnabled is true', () => { + expect(hasConfiguredRules(createMock({requireCompanyCardsEnabled: true}), undefined, false)).toBe(true); + }); + + it('returns false when requireCompanyCardsEnabled is false', () => { + expect(hasConfiguredRules(createMock({requireCompanyCardsEnabled: false}), undefined, false)).toBe(false); + }); }); - it('returns false when eReceipts is false', () => { - expect(hasConfiguredRules(createMock({eReceipts: false}), undefined, false)).toBe(false); + describe('prohibitedExpenses', () => { + it('returns true when a prohibitedExpenses value differs from its default', () => { + // alcohol defaults to false — setting it to true triggers the rule + expect(hasConfiguredRules(createMock({prohibitedExpenses: {alcohol: true}}), undefined, false)).toBe(true); + }); + + it('returns true when gambling is disabled (differs from default true)', () => { + expect(hasConfiguredRules(createMock({prohibitedExpenses: {gambling: false}}), undefined, false)).toBe(true); + }); + + it('returns false when prohibitedExpenses matches all defaults', () => { + expect(hasConfiguredRules(createMock({prohibitedExpenses: {...CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES}}), undefined, false)).toBe(false); + }); + + it('returns false when prohibitedExpenses is an empty object', () => { + expect(hasConfiguredRules(createMock({prohibitedExpenses: {}}), undefined, false)).toBe(false); + }); }); - }); - describe('requireCompanyCardsEnabled', () => { - it('returns true when requireCompanyCardsEnabled is true', () => { - expect(hasConfiguredRules(createMock({requireCompanyCardsEnabled: true}), undefined, false)).toBe(true); + it('returns true when only Classic category rules exist', () => { + const categories = {Travel: {name: 'Travel', enabled: true, maxAmountNoReceipt: 0}}; + expect(hasConfiguredRules(createMock({}), categories, false)).toBe(true); }); - it('returns false when requireCompanyCardsEnabled is false', () => { - expect(hasConfiguredRules(createMock({requireCompanyCardsEnabled: false}), undefined, false)).toBe(false); + it('returns false when categories have no active rule fields', () => { + const categories = {Advertising: {name: 'Advertising', enabled: true, 'GL Code': '1234'}}; + expect(hasConfiguredRules(createMock({}), categories, false)).toBe(false); }); }); - describe('prohibitedExpenses', () => { - it('returns true when a prohibitedExpenses value differs from its default', () => { - // alcohol defaults to false — setting it to true triggers the rule - expect(hasConfiguredRules(createMock({prohibitedExpenses: {alcohol: true}}), undefined, false)).toBe(true); + describe('isMaxExpenseAmountSet', () => { + it('returns false when the amount was never set', () => { + expect(isMaxExpenseAmountSet(undefined)).toBe(false); }); - it('returns true when gambling is disabled (differs from default true)', () => { - expect(hasConfiguredRules(createMock({prohibitedExpenses: {gambling: false}}), undefined, false)).toBe(true); + it('returns false when the amount is explicitly disabled', () => { + expect(isMaxExpenseAmountSet(CONST.DISABLED_MAX_EXPENSE_VALUE)).toBe(false); }); - it('returns false when prohibitedExpenses matches all defaults', () => { - expect(hasConfiguredRules(createMock({prohibitedExpenses: {...CONST.POLICY.DEFAULT_PROHIBITED_EXPENSES}}), undefined, false)).toBe(false); + it('returns true for an explicit 0, which means the receipt is always required', () => { + expect(isMaxExpenseAmountSet(0)).toBe(true); }); - it('returns false when prohibitedExpenses is an empty object', () => { - expect(hasConfiguredRules(createMock({prohibitedExpenses: {}}), undefined, false)).toBe(false); + it('returns true for a positive amount', () => { + expect(isMaxExpenseAmountSet(5000)).toBe(true); }); }); - - it('returns true when only Classic category rules exist', () => { - const categories = {Travel: {name: 'Travel', enabled: true, maxAmountNoReceipt: 0}}; - expect(hasConfiguredRules(createMock({}), categories, false)).toBe(true); - }); - - it('returns false when categories have no active rule fields', () => { - const categories = {Advertising: {name: 'Advertising', enabled: true, 'GL Code': '1234'}}; - expect(hasConfiguredRules(createMock({}), categories, false)).toBe(false); - }); }); describe('getExpensifyTeamExclusions', () => { @@ -6186,46 +6464,6 @@ describe('PolicyUtils', () => { }); }); - describe('hasPolicyRulesError', () => { - // Whether a merchant rule failed is reduced by the caller's selector and covered in - // ExpenseDefaultRuleUtilsTest, so only the agent rules and the pass-through are checked here. - const POLICY_ID = 'policy-with-rules'; - - it('returns false for an undefined policy', () => { - expect(hasPolicyRulesError(undefined, false)).toBe(false); - }); - - it('returns false when no merchant or agent rules exist', () => { - const policy: Policy = {...createRandomPolicy(0), id: POLICY_ID, rules: {}}; - expect(hasPolicyRulesError(policy, false)).toBe(false); - }); - - it('returns false when agent rules exist but none have errors', () => { - const policy: Policy = { - ...createRandomPolicy(0), - id: POLICY_ID, - rules: {agentRules: {ai1: {ruleID: 'ai1', prompt: 'p', created: '2026-06-08'}}}, - }; - expect(hasPolicyRulesError(policy, false)).toBe(false); - }); - - it('returns true when a merchant rule has errors', () => { - const policy: Policy = {...createRandomPolicy(0), id: POLICY_ID, rules: {}}; - expect(hasPolicyRulesError(policy, true)).toBe(true); - }); - - it('returns true when an agent rule has errors', () => { - const policy: Policy = { - ...createRandomPolicy(0), - id: POLICY_ID, - rules: { - agentRules: {ai1: {ruleID: 'ai1', prompt: 'p', created: '2026-06-08', errors: {123: 'boom'}}}, - }, - }; - expect(hasPolicyRulesError(policy, false)).toBe(true); - }); - }); - describe('getExcludedUsers', () => { it('marks every active policy member as excluded', () => { const result = getExcludedUsers(employeeList); @@ -6263,72 +6501,6 @@ describe('PolicyUtils', () => { }); }); - describe('isQBORefreshTokenExpiringSoonSelector', () => { - const buildQBOPolicy = (role: string, refreshTokenExpiresAt: number): Policy => - Object.assign(createRandomPolicy(1), { - role, - connections: {quickbooksOnline: {config: {credentials: {companyID: '12345', refreshTokenExpiresAt}}, lastSync: {isAuthenticationError: false}}}, - }); - const expiringSoon = Math.floor(Date.now() / 1000) + 3 * 86400; - - it('returns true for an admin whose QBO refresh token expires within the warning window', () => { - expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.ADMIN, expiringSoon))).toBe(true); - }); - - it('returns false for a member, since only admins can reconnect', () => { - expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.USER, expiringSoon))).toBe(false); - }); - - it('returns false when the token is still far from expiring or the policy is undefined', () => { - expect(isQBORefreshTokenExpiringSoonSelector(buildQBOPolicy(CONST.POLICY.ROLE.ADMIN, Math.floor(Date.now() / 1000) + 60 * 86400))).toBe(false); - expect(isQBORefreshTokenExpiringSoonSelector(undefined)).toBe(false); - }); - }); - - describe('isMergeHRCompleteSetupNeededSelector', () => { - // Only the fields read by the selector are modeled here; `Object.assign` attaches the connection - // to a real policy without an unsafe cast. - type MergeHRTestConnection = { - lastSync?: {syncStatus?: string}; - data?: {groups?: unknown[]}; - config?: {groups?: unknown[] | null}; - }; - const buildMergeHRPolicy = (seed: number, mergeHR: MergeHRTestConnection): Policy => Object.assign(createRandomPolicy(seed), {connections: {merge_hris: mergeHR}}); - const mergeHRBase = { - lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, - data: {groups: [{id: 'g1', name: 'Engineering'}]}, - }; - - it('returns false when policy is undefined', () => { - expect(isMergeHRCompleteSetupNeededSelector(undefined)).toBe(false); - }); - - it('returns false when policy has no merge_hris connection', () => { - const policy = createRandomPolicy(1); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); - }); - - it('returns false when sync is not done', () => { - const policy = buildMergeHRPolicy(2, {...mergeHRBase, lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.SYNCING}}); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); - }); - - it('returns false when sync is done but there are no groups', () => { - const policy = buildMergeHRPolicy(3, {...mergeHRBase, data: {groups: []}}); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); - }); - - it('returns false when setup is already complete (groups configured)', () => { - const policy = buildMergeHRPolicy(4, {...mergeHRBase, config: {groups: ['g1']}}); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(false); - }); - - it('returns true when sync is done, groups exist, and setup is not yet complete', () => { - const policy = buildMergeHRPolicy(5, mergeHRBase); - expect(isMergeHRCompleteSetupNeededSelector(policy)).toBe(true); - }); - }); - describe('connections', () => { describe('getUberConnectionErrorDirectlyFromPolicy', () => { it('should return true if Uber connection is enabled and has an error', () => { @@ -6710,78 +6882,6 @@ describe('PolicyUtils', () => { }); }); -describe('isMaxExpenseAmountSet', () => { - it('returns false when the amount was never set', () => { - expect(isMaxExpenseAmountSet(undefined)).toBe(false); - }); - - it('returns false when the amount is explicitly disabled', () => { - expect(isMaxExpenseAmountSet(CONST.DISABLED_MAX_EXPENSE_VALUE)).toBe(false); - }); - - it('returns true for an explicit 0, which means the receipt is always required', () => { - expect(isMaxExpenseAmountSet(0)).toBe(true); - }); - - it('returns true for a positive amount', () => { - expect(isMaxExpenseAmountSet(5000)).toBe(true); - }); -}); - -describe('arePolicyRulesEnabled', () => { - const corporateBase = createMock({id: 'policy1', type: CONST.POLICY.TYPE.CORPORATE}); - const teamBase = createMock({id: 'policy2', type: CONST.POLICY.TYPE.TEAM}); - - it('returns true for a corporate policy with areRulesEnabled explicitly true', () => { - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: true})).toBe(true); - }); - - it('returns false for a corporate policy with areRulesEnabled explicitly false', () => { - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: false})).toBe(false); - }); - - it('returns false for a corporate policy with areRulesEnabled undefined and no categories', () => { - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined})).toBe(false); - }); - - it('returns false for a corporate policy with areRulesEnabled undefined and categories with no rule fields', () => { - const categories = {Advertising: {name: 'Advertising', enabled: true, 'GL Code': '1234'}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(false); - }); - - it('returns true for a corporate policy with areRulesEnabled undefined and a category with maxExpenseAmount set', () => { - const categories = {Advertising: {name: 'Advertising', enabled: true, maxExpenseAmount: 50000}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); - }); - - it('returns true for a corporate policy with areRulesEnabled undefined and a category with maxAmountNoReceipt = 0', () => { - const categories = {Travel: {name: 'Travel', enabled: true, maxAmountNoReceipt: 0}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); - }); - - it('returns true for a corporate policy with areRulesEnabled undefined and a category with areCommentsRequired = true', () => { - const categories = {Meals: {name: 'Meals', enabled: true, areCommentsRequired: true}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); - }); - - it('returns true for a corporate policy with areRulesEnabled undefined and a category with commentHint set', () => { - const categories = {Car: {name: 'Car', enabled: true, commentHint: 'Enter purpose'}}; - expect(arePolicyRulesEnabled({...corporateBase, areRulesEnabled: undefined}, categories)).toBe(true); - }); - - it('returns false for a team policy with areRulesEnabled undefined', () => { - expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: undefined})).toBe(false); - }); - - it('returns true for a team policy with areRulesEnabled explicitly true', () => { - expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: true}, undefined)).toBe(true); - }); - - it('returns false for a team policy with areRulesEnabled explicitly false', () => { - expect(arePolicyRulesEnabled({...teamBase, areRulesEnabled: false})).toBe(false); - }); -}); - describe('getDefaultChatEnabledPolicy', () => { const submitPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), id: 'submit1'}; const teamPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: 'team1'}; @@ -7010,25 +7110,3 @@ describe('isMemberInHomeAndOfficeWorkspace', () => { expect(isEligibleForUnknownMember).toBe(false); }); }); - -describe('shouldHideDynamicExternalWorkflowPeople', () => { - it('returns false when the policy is undefined', () => { - expect(shouldHideDynamicExternalWorkflowPeople(undefined)).toBe(false); - }); - - it('returns true for a Dynamic External Workflow policy with the flag set', () => { - const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, dynamicExternalWorkflowHidePeople: true}; - expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(true); - }); - - // The backend only returns the flag when it is `true`, so an absent flag is the normal DEW case. - it('returns false for a Dynamic External Workflow policy without the flag', () => { - const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL}; - expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(false); - }); - - it('returns false when a stale flag is left on a policy that no longer uses a Dynamic External Workflow', () => { - const policy: Policy = {...createRandomPolicy(0), approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, dynamicExternalWorkflowHidePeople: true}; - expect(shouldHideDynamicExternalWorkflowPeople(policy)).toBe(false); - }); -}); From c41c003264333813ce700d628a795131d40d341f Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 14 Oct 2026 09:08:04 +0200 Subject: [PATCH 626/781] fix: limit dynamic form date of birth picker to accepted ages --- .../DynamicForm/renderers/textRenderers.tsx | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/components/DynamicForm/renderers/textRenderers.tsx b/src/components/DynamicForm/renderers/textRenderers.tsx index 9e7167883672..7a240126b953 100644 --- a/src/components/DynamicForm/renderers/textRenderers.tsx +++ b/src/components/DynamicForm/renderers/textRenderers.tsx @@ -14,6 +14,7 @@ import type {DynamicFormNumberField, DynamicFormTextField} from '@src/types/onyx import type {InputModeOptions} from 'react-native'; +import {subYears} from 'date-fns'; import React from 'react'; import {View} from 'react-native'; @@ -70,17 +71,23 @@ function renderTextInput( const TEXT_RENDERERS: Pick = { text: (field, context, inputProps) => renderTextInput(field, context, inputProps, getTextInputMode(field), field.multiline), number: (field, context, inputProps) => renderTextInput(field, context, inputProps, CONST.INPUT_MODE.NUMERIC), - date: (field, {translate}, inputProps) => ({ - isMenuRow: false, - input: ( - - ), - }), + date: (field, {translate}, inputProps) => { + const isDateOfBirth = field.rule === 'dateOfBirth'; + return { + isMenuRow: false, + input: ( + + ), + }; + }, amount: (field, {values, translate, styles, currency}, inputProps) => { const chosenCurrency = field.currencyKey ? values[field.currencyKey] : undefined; const amountCurrency = typeof chosenCurrency === 'string' && chosenCurrency !== '' ? chosenCurrency : currency; From 228ed2798b135553d9f4d9d48d591102f069dc67 Mon Sep 17 00:00:00 2001 From: "ahmedGaber93 (via MelvinBot)" Date: Wed, 14 Oct 2026 07:26:00 +0000 Subject: [PATCH 627/781] Send complete wallet details after the phone validateCode and show failures The validateCode screen rebuilt the personal details from the form draft, which only holds fields the user edited, so pre-filled values like the legal name were sent empty and the request failed with no Onyx data. The screen then treated the failure as success and looped back to the form. Save all submitted details to the draft before opening the screen, and set a generic error on failure so it is never treated as success. Co-authored-by: ahmedGaber93 --- src/libs/actions/Wallet.ts | 14 ++++++++ .../shared/useWalletPhoneValidateCode.ts | 14 ++++++++ tests/unit/useWalletPhoneMagicCodeTest.tsx | 33 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index 2102aa1cece2..9731f39a7092 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -1,6 +1,7 @@ import * as API from '@libs/API'; import type {AcceptWalletTermsParams, AnswerQuestionsForWalletParams, UpdatePersonalDetailsForWalletParams, VerifyIdentityParams} from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import Log from '@libs/Log'; import CONST from '@src/CONST'; @@ -86,6 +87,18 @@ function updatePersonalDetails(personalDetails: UpdatePersonalDetailsForWalletPa }, ]; + // Some failures (e.g. a missing field) come back without any Onyx data, so set an error here to keep the flow from + // treating the request as successful. + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.WALLET_ADDITIONAL_DETAILS, + value: { + errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + ]; + const finallyData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -98,6 +111,7 @@ function updatePersonalDetails(personalDetails: UpdatePersonalDetailsForWalletPa API.write(WRITE_COMMANDS.UPDATE_PERSONAL_DETAILS_FOR_WALLET, personalDetails, { optimisticData, + failureData, finallyData, }); } diff --git a/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts b/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts index 5c266c0cb7c3..a77744673f13 100644 --- a/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts +++ b/src/pages/EnablePayments/shared/useWalletPhoneValidateCode.ts @@ -4,6 +4,7 @@ import type {UpdatePersonalDetailsForWalletParams} from '@libs/API/parameters'; import Navigation from '@libs/Navigation/Navigation'; import {parsePhoneNumber} from '@libs/PhoneNumber'; +import {setDraftValues} from '@userActions/FormActions'; import {updatePersonalDetails} from '@userActions/Wallet'; import CONST from '@src/CONST'; @@ -32,6 +33,19 @@ function useWalletPhoneValidateCode(getValidateCodeRoute: () => Route = ROUTES.S const normalizedStoredPhoneNumber = (storedPhoneNumber && parsePhoneNumber(storedPhoneNumber, {regionCode: CONST.COUNTRY.US}).number?.significant) ?? ''; const isSettingPhoneNumber = !!personalDetails.phoneNumber && personalDetails.phoneNumber !== normalizedStoredPhoneNumber; if (isSettingPhoneNumber) { + // The validateCode screen rebuilds the details from the form draft, which only holds the fields the user + // edited. Save everything being submitted so pre-filled values (e.g. the legal name) are not sent empty. + setDraftValues(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS, { + legalFirstName: personalDetails.legalFirstName, + legalLastName: personalDetails.legalLastName, + addressStreet: personalDetails.addressStreet, + addressCity: personalDetails.addressCity, + addressState: personalDetails.addressState, + addressZipCode: personalDetails.addressZip, + dob: personalDetails.dob, + ssn: personalDetails.ssn, + phoneNumber: personalDetails.phoneNumber, + }); Navigation.navigate(getValidateCodeRoute()); return; } diff --git a/tests/unit/useWalletPhoneMagicCodeTest.tsx b/tests/unit/useWalletPhoneMagicCodeTest.tsx index 64ad7464f514..cea56914cb2b 100644 --- a/tests/unit/useWalletPhoneMagicCodeTest.tsx +++ b/tests/unit/useWalletPhoneMagicCodeTest.tsx @@ -10,6 +10,7 @@ import ROUTES from '@src/ROUTES'; import Onyx from 'react-native-onyx'; +import getOnyxValue from '../utils/getOnyxValue'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const mockUpdatePersonalDetails = jest.fn(); @@ -110,6 +111,38 @@ describe('useWalletPhoneValidateCode', () => { expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_ENABLE_PAYMENTS_CONFIRM_VALIDATE_CODE.getRoute()); }); + it('saves every submitted detail to the form draft before routing to the validateCode screen', async () => { + // Given a stored phone number and a form draft that only holds the field the user edited, since pre-filled + // values (e.g. the legal name) are never drafted + await act(async () => { + await setStoredPhoneNumber(STORED_PHONE_NUMBER_WITH_COUNTRY_CODE); + await Onyx.set(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS_DRAFT, {phoneNumber: CHANGED_PHONE_NUMBER}); + await waitForBatchedUpdates(); + }); + const {result} = await renderWalletPhoneValidateCode(); + + // When the phone number is changed, which holds the submission until the validateCode is entered + await act(async () => { + result.current.submitPersonalDetails(buildParams({phoneNumber: CHANGED_PHONE_NUMBER})); + await waitForBatchedUpdates(); + }); + + // Then the draft holds every submitted detail, so the validateCode screen sends the complete request + const draft = await getOnyxValue(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS_DRAFT); + expect(draft).toEqual({ + legalFirstName: 'Alberta', + legalLastName: 'Charleson', + addressStreet: '123 Main St', + addressCity: 'City', + addressState: 'CA', + addressZipCode: '90001', + dob: '2000-08-08', + ssn: '123456789', + phoneNumber: CHANGED_PHONE_NUMBER, + }); + expect(mockUpdatePersonalDetails).not.toHaveBeenCalled(); + }); + it('routes to the validateCode screen of the calling flow when it provides its own route', async () => { // Given the IOU pay flow, which must open the validateCode screen on top of itself rather than the Settings wallet one const payFlowRoute: Route = ROUTES.ENABLE_PAYMENTS; From 92e25c6f4f81b8cfcab395ae8dbf9775f50c8dd3 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 14 Oct 2026 09:35:01 +0200 Subject: [PATCH 628/781] fix: share one row renderer in ConfirmationStep to satisfy lint seatbelt --- .../SubStepForms/ConfirmationStep.tsx | 45 +++++++------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/src/components/SubStepForms/ConfirmationStep.tsx b/src/components/SubStepForms/ConfirmationStep.tsx index 922f6de62617..84e10a568e2a 100644 --- a/src/components/SubStepForms/ConfirmationStep.tsx +++ b/src/components/SubStepForms/ConfirmationStep.tsx @@ -106,6 +106,21 @@ function ConfirmationStep({ const {paddingBottom: safeAreaInsetPaddingBottom} = useSafeAreaPaddings(); + const renderRow = ({id, description, title, shouldShowRightIcon, onPress, interactive, brickRoadIndicator, errorText, testID}: SummaryItem) => ( + + ); + return ( {pageTitle} {!!pageSubtitle && {pageSubtitle}} - {summaryItems.map(({id, description, title, shouldShowRightIcon, onPress, interactive, brickRoadIndicator, errorText, testID}) => ( - - ))} + {summaryItems.map(renderRow)} {groups.map((group, index) => ( @@ -139,20 +141,7 @@ function ConfirmationStep({ {group.name} )} - {group.rows.map(({id, description, title, shouldShowRightIcon, onPress, interactive, brickRoadIndicator, errorText, testID}) => ( - - ))} + {group.rows.map(renderRow)} ))} From 7c6cc2276526d689afba2a6015ee54f2634ca812 Mon Sep 17 00:00:00 2001 From: "ahmedGaber93 (via MelvinBot)" Date: Wed, 14 Oct 2026 08:36:32 +0000 Subject: [PATCH 629/781] Remove generic failureData from updatePersonalDetails and rely on the backend WALLET_ADDITIONAL_DETAILS error Co-authored-by: ahmedGaber93 --- src/libs/actions/Wallet.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index 9731f39a7092..2102aa1cece2 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -1,7 +1,6 @@ import * as API from '@libs/API'; import type {AcceptWalletTermsParams, AnswerQuestionsForWalletParams, UpdatePersonalDetailsForWalletParams, VerifyIdentityParams} from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import Log from '@libs/Log'; import CONST from '@src/CONST'; @@ -87,18 +86,6 @@ function updatePersonalDetails(personalDetails: UpdatePersonalDetailsForWalletPa }, ]; - // Some failures (e.g. a missing field) come back without any Onyx data, so set an error here to keep the flow from - // treating the request as successful. - const failureData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.WALLET_ADDITIONAL_DETAILS, - value: { - errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), - }, - }, - ]; - const finallyData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -111,7 +98,6 @@ function updatePersonalDetails(personalDetails: UpdatePersonalDetailsForWalletPa API.write(WRITE_COMMANDS.UPDATE_PERSONAL_DETAILS_FOR_WALLET, personalDetails, { optimisticData, - failureData, finallyData, }); } From c84fd57f2466e6ea09caed6fb354024dab18a41f Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 14 Oct 2026 10:37:10 +0200 Subject: [PATCH 630/781] fix: mWeb offline indicator sits on the tab bar like iOS, footer and indicator clear the bottom safe area --- .../useTabBarCoveredHeight/index.ts | 6 ++++-- .../useTabRootScreenWrapperProps/index.ios.tsx | 2 +- .../useTabRootScreenWrapperProps/index.tsx | 3 ++- src/pages/Search/SearchPageNarrow/index.tsx | 2 +- src/styles/index.ts | 18 +++++++++--------- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ts b/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ts index 1c05afaa8f8e..02febb16d947 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabBarCoveredHeight/index.ts @@ -1,12 +1,14 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import variables from '@styles/variables'; -/** The floating tab bar and the gap under it cover the bottom of a narrow tab root screen. */ +/** The floating tab bar, the gap under it and the bottom safe area it sits on cover the bottom of a narrow tab root screen. */ function useTabBarCoveredHeight(): number { const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {paddingBottom: safeAreaPaddingBottom} = useSafeAreaPaddings(true); - return shouldUseNarrowLayout ? variables.floatingTabBarHeight + variables.floatingTabBarBottomInset : 0; + return shouldUseNarrowLayout ? variables.floatingTabBarHeight + variables.floatingTabBarBottomInset + safeAreaPaddingBottom : 0; } export default useTabBarCoveredHeight; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx index 1f4249c73266..c7176d66e24b 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.ios.tsx @@ -26,7 +26,7 @@ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['sel return { enableEdgeToEdgeBottomSafeAreaPadding: false, - offlineIndicatorStyle: styles.nativeTabBarOfflineIndicator(tabBarCoveredHeight), + offlineIndicatorStyle: styles.tabBarOfflineIndicator(tabBarCoveredHeight), }; } diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx index 009834598c06..7826ebaec323 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps/index.tsx @@ -12,7 +12,8 @@ import TabBarBottomContent from '..'; /** * On narrow layouts the floating tab bar is laid over the bottom of a tab root screen, so the content runs under it - * down to the bottom edge of the screen. Each tab root's list and the offline indicator end with room for the bar. + * down to the bottom edge of the screen. Each tab root's list ends with room for the bar, and the offline indicator sits + * on the bar with its background running down to the bottom edge. */ function useTabRootScreenWrapperProps(selectedTab: TabBarBottomContentProps['selectedTab']): TabRootScreenWrapperProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 4bf250fba847..28e04ea561cf 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -275,7 +275,7 @@ function SearchPageNarrow({ shouldEnableMaxHeight shouldShowOfflineIndicator={!!searchResults} {...tabRootScreenWrapperProps} - offlineIndicatorStyle={[styles.mtAuto, footerHeight > 0 ? styles.tabBarOfflineIndicator(footerHeight) : tabRootScreenWrapperProps.offlineIndicatorStyle]} + offlineIndicatorStyle={[styles.mtAuto, footerHeight > 0 ? styles.offlineIndicatorAboveFooter(footerHeight) : tabRootScreenWrapperProps.offlineIndicatorStyle]} > {!isMobileSelectionModeEnabled ? ( diff --git a/src/styles/index.ts b/src/styles/index.ts index fbdb95751f34..26130b914413 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -7096,27 +7096,27 @@ const staticStyles = (theme: ThemeColors) => const dynamicStyles = (theme: ThemeColors) => ({ - // The tab bar is laid over the bottom of a tab root screen, so the offline indicator floats above it without - // shrinking the content that runs under the bar. - tabBarOfflineIndicator: (bottom: number) => + // Floats the offline indicator above a footer laid over the bottom of a tab root screen, without shrinking the + // content that runs under the footer. + offlineIndicatorAboveFooter: (footerHeight: number) => ({ position: 'absolute', right: 0, - bottom, + bottom: footerHeight, left: 0, backgroundColor: theme.appBG, }) satisfies ViewStyle, - // Fills the screen from the top of the native tab bar down to the bottom edge, so no content shows between - // the offline indicator and the bar. - nativeTabBarOfflineIndicator: (tabBarHeight: number) => + // Fills the screen from the top of the tab bar down to the bottom edge, so no content shows between the + // offline indicator and the bar. + tabBarOfflineIndicator: (tabBarCoveredHeight: number) => ({ position: 'absolute', right: 0, bottom: 0, left: 0, - height: CONST.OFFLINE_INDICATOR_HEIGHT + tabBarHeight, - paddingBottom: tabBarHeight, + height: CONST.OFFLINE_INDICATOR_HEIGHT + tabBarCoveredHeight, + paddingBottom: tabBarCoveredHeight, backgroundColor: theme.appBG, }) satisfies ViewStyle, From e1e532912c9322bff965b559e539bfe362472818 Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Wed, 14 Oct 2026 08:54:44 +0000 Subject: [PATCH 631/781] Keep ArrowDown wrapping on the invite list while ArrowUp from the first row still reaches the search input Co-authored-by: huult --- .../hooks/useSelectionListKeyboardFocus.ts | 3 +- src/hooks/useArrowKeyFocusManager.ts | 12 ++++--- tests/unit/useArrowKeyFocusManagerTest.ts | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts index 4212d475e512..62b8561cc370 100644 --- a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts +++ b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts @@ -84,8 +84,7 @@ function useSelectionListKeyboardFocus({ }, setHasKeyBeenPressed, isFocused, - // Negative indexes let ArrowUp break out past a disabled section header at index 0 - disableCyclicTraversal: shouldAllowArrowUpToSearchInput, + // Negative indexes let ArrowUp leave the first row (even past a disabled section header at index 0) while ArrowDown still wraps allowNegativeIndexes: shouldAllowArrowUpToSearchInput, onArrowUpDownCallback: () => { setShouldDisableHoverStyle(true); diff --git a/src/hooks/useArrowKeyFocusManager.ts b/src/hooks/useArrowKeyFocusManager.ts index ce414d7a5831..cc5dc846b085 100644 --- a/src/hooks/useArrowKeyFocusManager.ts +++ b/src/hooks/useArrowKeyFocusManager.ts @@ -49,6 +49,7 @@ type UseArrowKeyFocusManager = [number, (index: number, shouldScrollHint?: boole * @param [config.itemsPerRow] – The number of items per row. If provided, the arrow keys will move focus horizontally as well as vertically * @param [config.disableCyclicTraversal] – Whether to disable cyclic traversal of the list. If true, the arrow keys will have no effect when the first or last item is focused * @param [config.allowHorizontalArrowKeys] – Whether to enable the right/left keys + * @param [config.allowNegativeIndexes] – Whether ArrowUp from the first enabled item moves focus to -1 (for example, back to a search input), even when cyclic traversal is on * @param [config.isFocused] Whether navigation is focused */ export default function useArrowKeyFocusManager({ @@ -114,7 +115,8 @@ export default function useArrowKeyFocusManager({ if (maxIndex < 0 || !isFocused) { return; } - const nextIndex = disableCyclicTraversal ? -1 : maxIndex; + // With negative indexes allowed, ArrowUp from the first item moves to -1 even when ArrowDown still wraps + const nextIndex = disableCyclicTraversal || allowNegativeIndexes ? -1 : maxIndex; setHasKeyBeenPressed?.(); setFocusedIndexInternal((actualIndex) => { const currentFocusedIndex = actualIndex > 0 ? actualIndex - (itemsPerRow ?? 1) : nextIndex; @@ -123,12 +125,12 @@ export default function useArrowKeyFocusManager({ while (disabledIndexes.includes(newFocusedIndex)) { newFocusedIndex -= itemsPerRow ?? 1; if (newFocusedIndex < 0) { - if (disableCyclicTraversal) { - if (!allowNegativeIndexes) { - return actualIndex; - } + if (allowNegativeIndexes) { break; } + if (disableCyclicTraversal) { + return actualIndex; + } newFocusedIndex = maxIndex; } if (newFocusedIndex === currentFocusedIndex) { diff --git a/tests/unit/useArrowKeyFocusManagerTest.ts b/tests/unit/useArrowKeyFocusManagerTest.ts index c4e75c13a550..386d76cc0155 100644 --- a/tests/unit/useArrowKeyFocusManagerTest.ts +++ b/tests/unit/useArrowKeyFocusManagerTest.ts @@ -209,4 +209,35 @@ describe('useArrowKeyFocusManager', () => { // Then focus returns to the first row, skipping the header expect(result.current[0]).toBe(1); }); + + it('moves ArrowUp from the first row to -1 but still wraps ArrowDown from the last row when only negative indexes are allowed', () => { + // Given a sectioned list (header at index 0) that lets ArrowUp leave the list but keeps cyclic traversal on + const {result} = renderHook(() => + useArrowKeyFocusManager({ + maxIndex: 3, + initialFocusedIndex: 3, + disabledIndexes: [0], + allowNegativeIndexes: true, + isActive: true, + }), + ); + + // When ArrowDown is pressed on the last row + pressArrowDown(); + + // Then focus wraps to the first row, skipping the header + expect(result.current[0]).toBe(1); + + // When ArrowUp is pressed on the first row + pressArrowUp(); + + // Then focus leaves the rows for the search input instead of wrapping to the last row + expect(result.current[0]).toBe(-1); + + // When ArrowDown is pressed from the search input + pressArrowDown(); + + // Then focus returns to the first row + expect(result.current[0]).toBe(1); + }); }); From 75ff5c8c15ac9db859b34de5423dfcfa0aec12c6 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 09:07:38 +0000 Subject: [PATCH 632/781] Update version to 9.5.8-5 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 85d4aee072a0..908595d69be6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050804 - versionName "9.5.8-4" + versionCode 1009050805 + versionName "9.5.8-5" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index e09fb2b24b29..c7dac0e9bdaa 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.8.4 + 9.5.8.5 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 22acf7c996d6..0327d387b87a 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.8 CFBundleVersion - 9.5.8.4 + 9.5.8.5 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index b8b01434878f..91e619206302 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.8 CFBundleVersion - 9.5.8.4 + 9.5.8.5 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 17a308b5253d..96ae4a79dd75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.8-4", + "version": "9.5.8-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.8-4", + "version": "9.5.8-5", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 5203b34d11ed..ed417b921076 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.8-4", + "version": "9.5.8-5", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From efd02d70fb44efe1a7cdca52e1b5d7ee5c949311 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 09:07:38 +0000 Subject: [PATCH 633/781] Update Mobile-Expensify submodule version to 9.5.8-5 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 903b4645c4e9..1fe3f8d3c912 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 903b4645c4e95b2a637d496db39b940292b84804 +Subproject commit 1fe3f8d3c91279fb6c94d1313e9777a442d17caa From 8a8489523537726075159102717e4dc02a051da9 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:24:14 +0100 Subject: [PATCH 634/781] show unmanagable agents as disabled rows --- src/components/Tables/AgentsTable/AgentsTableRow.tsx | 2 +- src/hooks/useWorkspaceAgents.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Tables/AgentsTable/AgentsTableRow.tsx b/src/components/Tables/AgentsTable/AgentsTableRow.tsx index 5f0c7a4b02fb..30004e39e081 100644 --- a/src/components/Tables/AgentsTable/AgentsTableRow.tsx +++ b/src/components/Tables/AgentsTable/AgentsTableRow.tsx @@ -52,7 +52,7 @@ export default function AgentsTableRow({item, rowIndex, shouldUseNarrowTableLayo return ( ) { login: details.login ?? '', role: policyRoles.find((policyRole) => policyRole === policyEmployee.role), pendingAction, - disabled: isPendingDeletion, + disabled: true, isSelectionDisabled: true, canManage: false, action: () => {}, From c81984535bf998e71506a51e230107c68657a1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Wed, 14 Oct 2026 11:40:44 +0200 Subject: [PATCH 635/781] Fix missing onboarding argument in LinkTest --- tests/unit/LinkTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/LinkTest.ts b/tests/unit/LinkTest.ts index 033e62271aa7..0e7843f23a7a 100644 --- a/tests/unit/LinkTest.ts +++ b/tests/unit/LinkTest.ts @@ -421,7 +421,7 @@ describe('Link.openReportFromDeepLink', () => { it('does not show an unfinished-setup error for a completed user on an onboarding task screen', async () => { await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); - openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/onboarding/work-email-validation?isJoinWorkspaceTask=true`, {}, true, undefined, undefined, false, 1, {}, {}); + openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/onboarding/work-email-validation?isJoinWorkspaceTask=true`, {}, true, undefined, undefined, false, true, 1, {}, {}); await waitForBatchedUpdates(); expect(mockedNavigation.waitForProtectedRoutes).toHaveBeenCalled(); From 948245891271b457baf685beaab996727f7ed9d2 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 14 Oct 2026 11:43:51 +0200 Subject: [PATCH 636/781] refactor: replace deprecated MenuItem usages in ListField and readonly dynamic form row --- .../components/DynamicFormFieldRow.tsx | 9 ++++---- src/components/ListField.tsx | 22 ++++++++++++++----- .../DynamicForm/DynamicFormFieldsTest.tsx | 4 ++-- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx index 06b51f2842f6..1c710810ea13 100644 --- a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx +++ b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx @@ -3,7 +3,7 @@ import type {DynamicFieldContext} from '@components/DynamicForm/renderers/types' import formatDynamicFieldValue from '@components/DynamicForm/utils/formatDynamicFieldValue'; import getLocalizedText, {getFieldLabel} from '@components/DynamicForm/utils/getLocalizedText'; import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -54,10 +54,9 @@ function DynamicFormFieldRow({ style={[styles.mhn5, styles.pv1]} fsClass={CONST.FULLSTORY.CLASS.MASK} > - ); diff --git a/src/components/ListField.tsx b/src/components/ListField.tsx index a56eeb6a1bf4..d849c25735cc 100644 --- a/src/components/ListField.tsx +++ b/src/components/ListField.tsx @@ -5,6 +5,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getLetterAvatarURLForName} from '@libs/UserAvatarUtils'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import variables from '@src/styles/variables'; @@ -106,12 +108,20 @@ function ListField({rows, addTitle, addDescription, canAddMore = true, errorText ))} {canAddMore && ( - + + + + + + + {addTitle} + {!!addDescription && {addDescription}} + + + )} {!!errorText && ( diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx index 47169b94d3d1..8ddaa3f0cc96 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx @@ -16,7 +16,7 @@ import DynamicFormFields from '@components/DynamicForm/components/DynamicFormFie import type {DynamicFormValues} from '@components/DynamicForm/types'; import getAddressInputKeys from '@components/DynamicForm/utils/getAddressInputKeys'; import type {FormValue} from '@components/Form/types'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import PercentageForm from '@components/PercentageForm'; import PushRowWithModal from '@components/PushRowWithModal'; import RadioButtons from '@components/RadioButtons'; @@ -324,7 +324,7 @@ describe('DynamicFormFields', () => { // Then no input is drawn, and the row shows the translated country, masked in session recordings since a prefilled answer is still personal expect(rendered.size).toBe(0); expect(screen.getByText('allCountries.GB')).toBeOnTheScreen(); - expect(isMasked(screen.UNSAFE_getByType(MenuItemWithTopDescription))).toBe(true); + expect(isMasked(screen.UNSAFE_getByType(MenuItemField))).toBe(true); }); it('draws a heading with its description above the fields that follow it', () => { From 3bf1ea73cd7452a4f5b3ab2b6a4e54a6ca447cb0 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:47:08 +0100 Subject: [PATCH 637/781] ts --- tests/unit/LinkTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/LinkTest.ts b/tests/unit/LinkTest.ts index 033e62271aa7..b22359973c28 100644 --- a/tests/unit/LinkTest.ts +++ b/tests/unit/LinkTest.ts @@ -421,7 +421,7 @@ describe('Link.openReportFromDeepLink', () => { it('does not show an unfinished-setup error for a completed user on an onboarding task screen', async () => { await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); - openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/onboarding/work-email-validation?isJoinWorkspaceTask=true`, {}, true, undefined, undefined, false, 1, {}, {}); + openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/onboarding/work-email-validation?isJoinWorkspaceTask=true`, {}, true, undefined, undefined, undefined, false, 1, {}, {}); await waitForBatchedUpdates(); expect(mockedNavigation.waitForProtectedRoutes).toHaveBeenCalled(); From d11c4b1d5df81ec28718546578815fd4e79997a4 Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Wed, 14 Oct 2026 09:52:52 +0000 Subject: [PATCH 638/781] Show workspace name in per diem empty state for members Co-authored-by: Eskalifer1 --- src/languages/de.ts | 2 +- src/languages/el.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 6 +++++- .../iou/request/step/DynamicIOURequestStepDestination.tsx | 6 +++++- 12 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 9443b7ef5276..6977ac0d07e6 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5102,7 +5102,7 @@ ${amount} für ${merchant} – ${date}`, }, requestEmptyList: { title: 'Noch keine Pauschalbeträge', - subtitle: 'In diesem Workspace sind keine Pauschalen hinterlegt, bitten Sie Ihre Admin, welche hinzuzufügen.', + subtitle: (workspaceName: string) => `In ${workspaceName} sind keine Pauschalen hinterlegt. Bitten Sie einen Admin, welche hinzuzufügen.`, adminSubtitle: 'Fügen Sie Pauschalen hinzu, um Ihre Ausgaben zu organisieren.', }, importPerDiemRates: 'Tagespauschalen importieren', diff --git a/src/languages/el.ts b/src/languages/el.ts index 45c848e2985e..1f0035116c39 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -5178,7 +5178,7 @@ ${amount} για ${merchant} - ${date}`, editCurrencySubtitle: (destination: string) => `Η ενημέρωση αυτού του νομίσματος θα το αλλάξει για όλες τις υποτιμές ημερήσιας αποζημίωσης ${destination}.`, requestEmptyList: { title: 'Δεν υπάρχουν ακόμη ημερήσια επιδόματα', - subtitle: 'Αυτός ο χώρος εργασίας δεν έχει ημερήσια επιδόματα· ζητήστε από τον διαχειριστή σας να προσθέσει κάποια.', + subtitle: (workspaceName: string) => `Ο χώρος εργασίας ${workspaceName} δεν έχει ημερήσια επιδόματα. Ζητήστε από έναν διαχειριστή να προσθέσει κάποια.`, adminSubtitle: 'Προσθέστε ημερήσια επιδόματα για να οργανώσετε τις δαπάνες σας.', }, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 082a4aed7b8d..187bf3e78651 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5283,7 +5283,7 @@ const translations = { }, requestEmptyList: { title: 'No per diem rates yet', - subtitle: "This workspace doesn't have any per diem rates, ask your Admin to add some.", + subtitle: (workspaceName: string) => `${workspaceName} doesn't have any per diem rates. Ask an admin to add some.`, adminSubtitle: 'Add per diem rates to organize your spend.', }, importPerDiemRates: 'Import per diem rates', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5a4f6962d2ca..c18bf5f9c1ef 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5024,7 +5024,7 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Aún no hay tasas de Per diem', - subtitle: 'Este espacio de trabajo no tiene ninguna tasa de per diem, pide a tu administrador que añada algunas.', + subtitle: (workspaceName: string) => `${workspaceName} no tiene ninguna tasa de per diem. Pide a un administrador que añada algunas.`, adminSubtitle: 'Añade tasas de per diem para organizar tus gastos.', }, importPerDiemRates: 'Importar tasas de per diem', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1397788a7159..7290c8da420b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5108,7 +5108,7 @@ ${amount} pour ${merchant} - ${date}`, }, requestEmptyList: { title: 'Aucun taux de per diem pour le moment', - subtitle: 'Cet espace de travail n’a aucun taux de per diem, demandez à votre Administrateur d’en ajouter.', + subtitle: (workspaceName: string) => `${workspaceName} n’a aucun taux de per diem. Demandez à un administrateur d’en ajouter.`, adminSubtitle: 'Ajoutez des taux de per diem pour organiser vos dépenses.', }, importPerDiemRates: 'Importer des taux de per diem', diff --git a/src/languages/it.ts b/src/languages/it.ts index 9b5a9461ab45..a65598777d79 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5070,7 +5070,7 @@ ${amount} per ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ancuna nessuna tariffa di diaria', - subtitle: 'Questo spazio di lavoro non ha nessuna diaria, chiedi al tuo amministratore di aggiungerne.', + subtitle: (workspaceName: string) => `${workspaceName} non ha nessuna diaria. Chiedi a un amministratore di aggiungerne.`, adminSubtitle: 'Aggiungi le tariffe giornaliere per organizzare le tue spese.', }, importPerDiemRates: 'Importa tariffe di diaria', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d4b6395a03d0..f69033f53987 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5033,7 +5033,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, requestEmptyList: { title: '日当レートがまだありません', - subtitle: 'このワークスペースには日当レートがありません。管理者に追加してもらってください。', + subtitle: (workspaceName: string) => `${workspaceName}には日当レートがありません。管理者に追加してもらってください。`, adminSubtitle: '日当額を追加して、支出を整理しましょう。', }, importPerDiemRates: '日当レートをインポート', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4fd0023754ec..bb58da78cc90 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5070,7 +5070,7 @@ ${amount} voor ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nog geen dagvergoedingen', - subtitle: 'Deze workspace heeft geen dagvergoedingen, vraag je admin om er een paar toe te voegen.', + subtitle: (workspaceName: string) => `${workspaceName} heeft geen dagvergoedingen. Vraag een admin om er een paar toe te voegen.`, adminSubtitle: 'Voeg dagvergoedingen toe om je uitgaven te organiseren.', }, importPerDiemRates: 'Daggeldtarieven importeren', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 829cfa260527..846113b145d5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5102,7 +5102,7 @@ ${amount} dla ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nie ma jeszcze stawek diet', - subtitle: 'W tym obszarze roboczym nie ma żadnych stawek diet, poproś swojego administratora o dodanie kilku.', + subtitle: (workspaceName: string) => `W ${workspaceName} nie ma żadnych stawek diet. Poproś administratora o dodanie kilku.`, adminSubtitle: 'Dodaj stawki diet, żeby uporządkować swoje wydatki.', }, importPerDiemRates: 'Zaimportuj stawki diet', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c48f53b822ab..ff169ca455ff 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5062,7 +5062,7 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ainda não há diárias', - subtitle: 'Este espaço de trabalho não tem nenhuma diária definida, peça para o seu administrador adicionar algumas.', + subtitle: (workspaceName: string) => `${workspaceName} não tem nenhuma diária definida. Peça a um administrador para adicionar algumas.`, adminSubtitle: 'Adicione diárias para organizar seus gastos.', }, importPerDiemRates: 'Importar tarifas de diária', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 80c14fa5170f..c37a361088d2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4917,7 +4917,11 @@ ${amount},商户:${merchant} - 日期:${date}`, title: '每日津贴', subtitle: '设置每日津贴标准以控制员工每日支出。可从电子表格导入标准以开始使用。', }, - requestEmptyList: {title: '尚未设置每日津贴费率', subtitle: '此工作区尚未设置任何每日津贴费率,请联系管理员添加。', adminSubtitle: '添加每日津贴标准,以整理您的支出。'}, + requestEmptyList: { + title: '尚未设置每日津贴费率', + subtitle: (workspaceName: string) => `${workspaceName}尚未设置任何每日津贴费率,请联系管理员添加。`, + adminSubtitle: '添加每日津贴标准,以整理您的支出。', + }, importPerDiemRates: '导入每日津贴费率', editPerDiemRate: '编辑每日补贴费率', editPerDiemRates: '编辑每日津贴费率', diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx index 0a572f2b8d7b..de9515f33d06 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx @@ -253,7 +253,11 @@ function DynamicIOURequestStepDestination({ shouldStyleAsCard={false} icon={illustrations.EmptyStateExpenses} title={translate('workspace.perDiem.requestEmptyList.title')} - subtitle={translate(isPolicyAdmin(policy) ? 'workspace.perDiem.requestEmptyList.adminSubtitle' : 'workspace.perDiem.requestEmptyList.subtitle')} + subtitle={ + isPolicyAdmin(policy) + ? translate('workspace.perDiem.requestEmptyList.adminSubtitle') + : translate('workspace.perDiem.requestEmptyList.subtitle', policy?.name ?? '') + } containerStyle={[styles.flex1, styles.justifyContentCenter]} /> {isPolicyAdmin(policy) && ( From 72d4ea62ec4b026468eca7089dab2b3190be91c8 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 14 Oct 2026 12:01:41 +0200 Subject: [PATCH 639/781] rephrasing --- src/languages/de.ts | 4 ++-- src/languages/el.ts | 4 ++-- src/languages/en.ts | 4 ++-- src/languages/es.ts | 4 ++-- src/languages/fr.ts | 4 ++-- src/languages/it.ts | 4 ++-- src/languages/ja.ts | 4 ++-- src/languages/nl.ts | 4 ++-- src/languages/pl.ts | 4 ++-- src/languages/pt-BR.ts | 4 ++-- src/languages/zh-hans.ts | 4 ++-- 11 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 2a1f9b25279d..13b8433da443 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7820,8 +7820,8 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, defaultName: ({officeNumber}: {officeNumber: number}) => `Bürostandort ${officeNumber}`, setAsPrimary: 'Als primär festlegen', primary: 'Primär', - deleteOfficeLocation: 'Bürostandort löschen', - deleteOfficeLocationConfirmation: 'Sind Sie sicher, dass Sie diesen Bürostandort löschen möchten?', + deleteOfficeLocation: 'Bürostandort löschen?', + deleteOfficeLocationConfirmation: 'Möchten Sie diesen Bürostandort wirklich löschen? Mitglieder werden dem primären Büro neu zugewiesen.', existingOfficeLocationError: 'Ein Bürostandort mit diesem Namen ist bereits vorhanden', setCompanyAddressAsPrimaryConfirmation: 'Die Firmenadresse als Hauptbüro festlegen?', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index 01675e13f7d2..41b3d458e731 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8120,8 +8120,8 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ defaultName: ({officeNumber}: {officeNumber: number}) => `Τοποθεσία γραφείου ${officeNumber}`, setAsPrimary: 'Ορισμός ως κύριο', primary: 'Κύριο', - deleteOfficeLocation: 'Διαγραφή τοποθεσίας γραφείου', - deleteOfficeLocationConfirmation: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την τοποθεσία γραφείου;', + deleteOfficeLocation: 'Διαγραφή τοποθεσίας γραφείου;', + deleteOfficeLocationConfirmation: 'Θέλετε πραγματικά να διαγράψετε αυτήν την τοποθεσία γραφείου; Τα μέλη θα ανατεθούν ξανά στο κύριο γραφείο.', existingOfficeLocationError: 'Υπάρχει ήδη τοποθεσία γραφείου με αυτό το όνομα', setCompanyAddressAsPrimaryConfirmation: 'Να οριστεί η εταιρική διεύθυνση ως κύριο γραφείο;', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 90bfe219fd04..2ed1cc241942 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8337,8 +8337,8 @@ const translations = { defaultName: ({officeNumber}: {officeNumber: number}) => `Office location ${officeNumber}`, setAsPrimary: 'Set as primary', primary: 'Primary', - deleteOfficeLocation: 'Delete office location', - deleteOfficeLocationConfirmation: 'Are you sure you want to delete this office location?', + deleteOfficeLocation: 'Delete office location?', + deleteOfficeLocationConfirmation: 'Do you really want to delete this office location? Members will be reassigned to the primary office.', existingOfficeLocationError: 'An office location with this name already exists', setCompanyAddressAsPrimaryConfirmation: 'Set the company address as primary office?', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index d4f890c9d509..acbce8b44bc2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7842,8 +7842,8 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, defaultName: ({officeNumber}: {officeNumber: number}) => `Ubicación de la oficina ${officeNumber}`, setAsPrimary: 'Establecer como principal', primary: 'Principal', - deleteOfficeLocation: 'Eliminar ubicación de oficina', - deleteOfficeLocationConfirmation: '¿Seguro que quieres eliminar esta oficina?', + deleteOfficeLocation: '¿Eliminar ubicación de oficina?', + deleteOfficeLocationConfirmation: '¿De verdad quieres eliminar esta ubicación de oficina? Los miembros se reasignarán a la oficina principal.', existingOfficeLocationError: 'Ya existe una ubicación de oficina con este nombre', setCompanyAddressAsPrimaryConfirmation: '¿Establecer la dirección de la empresa como oficina principal?', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index cc9682be0706..026920094467 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7841,8 +7841,8 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, defaultName: ({officeNumber}: {officeNumber: number}) => `Emplacement du bureau ${officeNumber}`, setAsPrimary: 'Définir comme principal', primary: 'Principal', - deleteOfficeLocation: 'Supprimer le bureau', - deleteOfficeLocationConfirmation: 'Voulez-vous vraiment supprimer ce bureau ?', + deleteOfficeLocation: 'Supprimer le bureau ?', + deleteOfficeLocationConfirmation: 'Voulez-vous vraiment supprimer ce bureau ? Les membres seront réaffectés au bureau principal.', existingOfficeLocationError: 'Un site de bureau portant ce nom existe déjà', setCompanyAddressAsPrimaryConfirmation: "Définir l'adresse de l'entreprise comme bureau principal ?", }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 2a80903f1b18..a10b2c2e5713 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7775,8 +7775,8 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, defaultName: ({officeNumber}: {officeNumber: number}) => `Sede ufficio ${officeNumber}`, setAsPrimary: 'Imposta come principale', primary: 'Primario', - deleteOfficeLocation: 'Elimina sede ufficio', - deleteOfficeLocationConfirmation: 'Sei sicuro di voler eliminare questa sede?', + deleteOfficeLocation: 'Eliminare sede ufficio?', + deleteOfficeLocationConfirmation: 'Vuoi davvero eliminare questa sede? I membri verranno riassegnati alla sede principale.', existingOfficeLocationError: 'Esiste già una sede con questo nome', setCompanyAddressAsPrimaryConfirmation: 'Impostare l’indirizzo dell’azienda come sede principale?', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index fca099eac4a8..053c2de52a22 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7680,8 +7680,8 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から defaultName: ({officeNumber}: {officeNumber: number}) => `オフィス所在地 ${officeNumber}`, setAsPrimary: '主要に設定', primary: 'メイン', - deleteOfficeLocation: 'オフィス所在地を削除', - deleteOfficeLocationConfirmation: 'このオフィス所在地を削除してもよろしいですか?', + deleteOfficeLocation: 'オフィス所在地を削除しますか?', + deleteOfficeLocationConfirmation: 'このオフィス所在地を本当に削除しますか?メンバーはメインのオフィスに再割り当てされます。', existingOfficeLocationError: 'この名前のオフィス所在地はすでに存在します', setCompanyAddressAsPrimaryConfirmation: '会社の住所を主なオフィスとして設定しますか?', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ee89ccc57a4c..e1971403cdb5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7755,8 +7755,8 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, defaultName: ({officeNumber}: {officeNumber: number}) => `Kantoorlocatie ${officeNumber}`, setAsPrimary: 'Instellen als primair', primary: 'Primair', - deleteOfficeLocation: 'Locatie kantoor verwijderen', - deleteOfficeLocationConfirmation: 'Weet je zeker dat je deze kantoorlocatie wilt verwijderen?', + deleteOfficeLocation: 'Locatie kantoor verwijderen?', + deleteOfficeLocationConfirmation: 'Wil je deze kantoorlocatie echt verwijderen? Leden worden opnieuw toegewezen aan het primaire kantoor.', existingOfficeLocationError: 'Er bestaat al een kantoorlocatie met deze naam', setCompanyAddressAsPrimaryConfirmation: 'Het bedrijfsadres instellen als hoofdvestiging?', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 22450bd66bf6..2d819b6b35ac 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7779,8 +7779,8 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, defaultName: ({officeNumber}: {officeNumber: number}) => `Lokalizacja biura ${officeNumber}`, setAsPrimary: 'Ustaw jako główny', primary: 'Podstawowe', - deleteOfficeLocation: 'Usuń lokalizację biura', - deleteOfficeLocationConfirmation: 'Czy na pewno chcesz usunąć tę lokalizację biura?', + deleteOfficeLocation: 'Usunąć lokalizację biura?', + deleteOfficeLocationConfirmation: 'Czy na pewno chcesz usunąć tę lokalizację biura? Członkowie zostaną przypisani do głównego biura.', existingOfficeLocationError: 'Lokalizacja biura o tej nazwie już istnieje', setCompanyAddressAsPrimaryConfirmation: 'Ustawić adres firmy jako główne biuro?', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 85ad99731b4e..04e6eadd3591 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7760,8 +7760,8 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, defaultName: ({officeNumber}: {officeNumber: number}) => `Local do escritório ${officeNumber}`, setAsPrimary: 'Definir como principal', primary: 'Principal', - deleteOfficeLocation: 'Excluir local do escritório', - deleteOfficeLocationConfirmation: 'Tem certeza de que quer excluir este escritório?', + deleteOfficeLocation: 'Excluir local do escritório?', + deleteOfficeLocationConfirmation: 'Deseja realmente excluir este escritório? Os membros serão reatribuídos ao escritório principal.', existingOfficeLocationError: 'Já existe um local de escritório com esse nome', setCompanyAddressAsPrimaryConfirmation: 'Definir o endereço da empresa como escritório principal?', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 75c08ddcab9b..77ce1a23d282 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7489,8 +7489,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM defaultName: ({officeNumber}: {officeNumber: number}) => `办公室位置 ${officeNumber}`, setAsPrimary: '设为主要', primary: '主要', - deleteOfficeLocation: '删除办公地点', - deleteOfficeLocationConfirmation: '确定要删除此办公室地点吗?', + deleteOfficeLocation: '删除办公地点?', + deleteOfficeLocationConfirmation: '确定要删除此办公室地点吗?成员将被重新分配到主要办公地点。', existingOfficeLocationError: '已存在同名的办公地点', setCompanyAddressAsPrimaryConfirmation: '将公司地址设为主要办公地点?', }, From f33e6176a85b4f4ffb7ed0661961a5db7b24b78a Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 14 Oct 2026 12:05:32 +0200 Subject: [PATCH 640/781] translated logs --- src/libs/ReportAlternateTextUtils.ts | 12 +++++ .../report/ContextMenu/ContextMenuActions.tsx | 9 ++++ .../unit/ContextMenuActionsCopyMessageTest.ts | 38 +++++++++++++++ tests/unit/ReportAlternateTextUtilsTest.ts | 48 +++++++++++++++++++ 4 files changed, 107 insertions(+) diff --git a/src/libs/ReportAlternateTextUtils.ts b/src/libs/ReportAlternateTextUtils.ts index 8f01c40407f9..2a1def437e41 100644 --- a/src/libs/ReportAlternateTextUtils.ts +++ b/src/libs/ReportAlternateTextUtils.ts @@ -48,6 +48,7 @@ import { getAddedBudgetMessage, getAddedCardFeedMessage, getAddedConnectionMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -69,6 +70,7 @@ import { getDelegateSubmitMessage, getDeletedApprovalRuleMessage, getDeletedBudgetMessage, + getDeletedOfficeLocationMessage, getDynamicExternalWorkflowRoutedMessage, getExportIntegrationLastMessageText, getForeignCurrencyDefaultTaxUpdateMessage, @@ -136,6 +138,7 @@ import { getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, getUpdatedOwnershipMessage, getUpdatedProhibitedExpensesMessage, getUpdatedReimbursementChoiceMessage, @@ -860,6 +863,15 @@ function getLastMessageTextForReport({ if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT)) { lastMessageTextFromReport = getUpdatedMemberWorkArrangementMessage(translate, lastReportAction); } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION)) { + lastMessageTextFromReport = getAddedOfficeLocationMessage(translate, lastReportAction); + } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION)) { + lastMessageTextFromReport = getUpdatedOfficeLocationMessage(translate, lastReportAction); + } + if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { + lastMessageTextFromReport = getDeletedOfficeLocationMessage(translate, lastReportAction); + } if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT)) { lastMessageTextFromReport = getAutoReimbursementMessage(translate, lastReportAction, convertToDisplayString); } diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index e96364ae4c88..be41bd9b9378 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -35,6 +35,7 @@ import { getAddedBudgetMessage, getAddedCardFeedMessage, getAddedConnectionMessage, + getAddedOfficeLocationMessage, getApprovalLimitUpdateMessage, getAssignedCompanyCardMessage, getAutoCategorizeNewExpensesMessage, @@ -56,6 +57,7 @@ import { getDelegateSubmitMessage, getDeletedApprovalRuleMessage, getDeletedBudgetMessage, + getDeletedOfficeLocationMessage, getDismissedViolationMessageText, getDynamicExternalWorkflowApproveFailedActionMessage, getDynamicExternalWorkflowRoutedMessage, @@ -118,6 +120,7 @@ import { getUpdatedIndividualBudgetNotificationMessage, getUpdatedManualApprovalThresholdMessage, getUpdatedMemberWorkArrangementMessage, + getUpdatedOfficeLocationMessage, getUpdatedOwnershipMessage, getUpdatedProhibitedExpensesMessage, getUpdatedReimbursementChoiceMessage, @@ -1483,6 +1486,12 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getUpdatedAutoHarvestingMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MEMBER_WORK_ARRANGEMENT)) { setClipboardMessage(getUpdatedMemberWorkArrangementMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION)) { + setClipboardMessage(getAddedOfficeLocationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION)) { + setClipboardMessage(getUpdatedOfficeLocationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION)) { + setClipboardMessage(getDeletedOfficeLocationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION)) { setClipboardMessage(getUpdatedIndividualBudgetNotificationMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION)) { diff --git a/tests/unit/ContextMenuActionsCopyMessageTest.ts b/tests/unit/ContextMenuActionsCopyMessageTest.ts index 1d8a6f6f14e9..7e824a53eff5 100644 --- a/tests/unit/ContextMenuActionsCopyMessageTest.ts +++ b/tests/unit/ContextMenuActionsCopyMessageTest.ts @@ -260,6 +260,44 @@ describe('ContextMenuActions copy message', () => { expect(mockSetString).toHaveBeenCalledWith('updated work arrangement'); }); + it.each([ + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}}, + 'workspaceActions.officeLocation.added', + ], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'HQ', updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.NAME, newValue: 'HQ', oldValue: 'Headquarters'}, + 'workspaceActions.officeLocation.renamed', + ], + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION, {officeID: 'OFFICE1', name: 'Headquarters'}, 'workspaceActions.officeLocation.removed'], + ] as const)('copies the localized message for a %s action', (actionName, originalMessage, translationKey) => { + // Given an office location change log, whose stored text is Auth's English message + mockCanSetHtml.mockReturnValue(false); + mockGetClipboardText.mockReturnValue('office change'); + + if (!copyMessageAction?.onPress) { + throw new Error('Copy message context menu action was not found'); + } + + // When its message is copied + copyMessageAction.onPress( + false, + createReportActionPayload( + createMock({ + actionName, + message: [{text: 'stored message', html: 'stored message'}], + originalMessage, + }), + ), + ); + + // Then the translated change log text is copied instead of the stored text + expect(mockGetClipboardText).toHaveBeenCalledWith(translationKey, undefined); + expect(mockSetString).toHaveBeenCalledWith('office change'); + }); + it('copies the localized member work arrangement message as html when supported', () => { // Given the clipboard supports HTML, when the member arrangement message is copied, then the shared rich clipboard path is used. mockCanSetHtml.mockReturnValue(true); diff --git a/tests/unit/ReportAlternateTextUtilsTest.ts b/tests/unit/ReportAlternateTextUtilsTest.ts index ba0a8fab9eff..787d62d14ef2 100644 --- a/tests/unit/ReportAlternateTextUtilsTest.ts +++ b/tests/unit/ReportAlternateTextUtilsTest.ts @@ -24,6 +24,9 @@ import { getRequiresCategoryMessage, getRequiresTagMessage, getPolicyWorkArrangementMessage, + getAddedOfficeLocationMessage, + getUpdatedOfficeLocationMessage, + getDeletedOfficeLocationMessage, getUnassignedCompanyCardMessage, getUpdatedAutoHarvestingMessage, getUpdatedCardFeedLiabilityMessage, @@ -1597,6 +1600,51 @@ describe('ReportAlternateTextUtils', () => { // Then the preview reads the same as the in-chat change log rather than the raw stored text expect(lastMessage).toBe(getPolicyWorkArrangementMessage(translateLocal, action)); }); + it.each([ + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}}, + getAddedOfficeLocationMessage, + ], + [ + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION, + {officeID: 'OFFICE1', name: 'HQ', updatedField: CONST.POLICY.OFFICE_LOCATION_FIELD.NAME, newValue: 'HQ', oldValue: 'Headquarters'}, + getUpdatedOfficeLocationMessage, + ], + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION, {officeID: 'OFFICE1', name: 'Headquarters'}, getDeletedOfficeLocationMessage], + ] as const)('%s action', async (actionName, originalMessage, getMessage) => { + // Given an office location change log, whose stored text is Auth's English message + const report: Report = createRandomReport(0, undefined); + const action: ReportAction = { + ...createRandomReportAction(1), + actionName, + message: [{type: 'COMMENT', text: 'stored message'}], + originalMessage, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [action.reportActionID]: action, + }); + + // When the sidebar resolves the report's last message + const lastMessage = getLastMessageTextForReport({ + rules: undefined, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + personalDetails: undefined, + translate: translateLocal, + report, + lastActorDetails: null, + policy: undefined, + isReportArchived: false, + + currentUserLogin: CURRENT_USER_LOGIN, + }); + + // Then the preview reads the same translated text as the in-chat change log rather than the stored text + expect(lastMessage).toBe(getMessage(translateLocal, action)); + }); it('UPDATE_AUTO_HARVESTING action', async () => { const report: Report = createRandomReport(0, undefined); const action: ReportAction = { From 748a27947182486d54c47a668dcd306ae71900cd Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 14 Oct 2026 12:06:26 +0200 Subject: [PATCH 641/781] old comments --- src/components/AddressForm.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index e3a3a5994643..a707afe9f413 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -258,13 +258,6 @@ function AddressForm({ const styles = useThemeStyles(); const {translate} = useLocalize(); - /** - * @param translate - translate function - * @param isUSAForm - selected country ISO code is US - * @param values - form input values - * @returns - An object containing the errors for each inputID - */ - const validator = useCallback( (rawValues: FormOnyxValues): FormInputErrors => { // Every form rendered here has the home address fields, so they are validated the same way From 931c484e75146a0addd6d35a5222851db7e6dd5f Mon Sep 17 00:00:00 2001 From: "Yauheni Horbach (via MelvinBot)" Date: Wed, 14 Oct 2026 10:09:08 +0000 Subject: [PATCH 642/781] Run export template actions after the popover menu hides Co-authored-by: Yauheni Horbach --- src/hooks/useSelectedTransactionsActions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index bd88e40df03f..904ab27f5107 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -466,6 +466,7 @@ function useSelectedTransactionsActions({ clearSelectedTransactions(true); } : () => beginExportWithTemplate(template.templateName, template.type, selectedTransactionIDs, template.name, template.policyID), + shouldCallAfterModalHide: true, addSeparatorBefore, }; }; From bceda13622eb5c2f02c59e608d6d93cb79007b03 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 14 Oct 2026 12:19:26 +0200 Subject: [PATCH 643/781] fix: keep sensitive dynamic form list entries out of drafts and readonly lists uneditable --- .../DynamicForm/adapters/ListFieldAdapter.tsx | 8 +++++++- .../components/DynamicFormFieldRow.tsx | 8 +++++--- .../components/DynamicFormFields.tsx | 12 +++++++++++- .../components/DynamicFormFieldsPage.tsx | 10 +++++++++- .../components/DynamicFormListItemPage.tsx | 11 ++++++++--- src/components/DynamicForm/index.tsx | 19 +++++++++++++++++-- .../DynamicForm/renderDynamicField.tsx | 2 +- .../DynamicForm/renderers/listRenderer.tsx | 9 ++++++++- src/components/DynamicForm/renderers/types.ts | 2 +- src/components/DynamicForm/types.ts | 2 ++ .../DynamicForm/utils/getAnswerKeys.ts | 17 +++++++++++++++++ .../DynamicForm/utils/getConfirmationItems.ts | 7 +++++-- .../utils/getSensitiveDefaultValue.ts | 10 +++++++--- .../DynamicForm/utils/getSubmittedAnswers.ts | 14 +------------- src/components/DynamicForm/utils/listItems.ts | 16 ++++++++++------ .../DynamicForm/DynamicFormFieldsPageTest.tsx | 1 + .../adapters/ListFieldAdapterTest.tsx | 2 +- 17 files changed, 111 insertions(+), 39 deletions(-) create mode 100644 src/components/DynamicForm/utils/getAnswerKeys.ts diff --git a/src/components/DynamicForm/adapters/ListFieldAdapter.tsx b/src/components/DynamicForm/adapters/ListFieldAdapter.tsx index 9d498dec7637..239a01b4232f 100644 --- a/src/components/DynamicForm/adapters/ListFieldAdapter.tsx +++ b/src/components/DynamicForm/adapters/ListFieldAdapter.tsx @@ -30,6 +30,9 @@ type ListFieldAdapterProps = Pick, 'addTitle' | /** Called with every entry after one is added, edited or removed */ onInputChange?: (value: DynamicFormListItem[]) => void; + /** Called with the remaining entries after one is removed, for a list the flow keeps in memory */ + onItemsChange?: (value: DynamicFormListItem[]) => void; + /** Validation error shown under the list */ errorText?: string; @@ -59,6 +62,7 @@ type ListFieldAdapterProps = Pick, 'addTitle' | function ListFieldAdapter({ value, onInputChange = () => {}, + onItemsChange, errorText, label, itemLabel, @@ -123,7 +127,9 @@ function ListFieldAdapter({ if (action !== ModalActions.CONFIRM) { return; } - onInputChange(items.filter((candidate) => candidate.id !== id)); + const remainingItems = items.filter((candidate) => candidate.id !== id); + onInputChange(remainingItems); + onItemsChange?.(remainingItems); }); }; diff --git a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx index 1c710810ea13..2b0f1b0b0048 100644 --- a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx +++ b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx @@ -17,7 +17,7 @@ import {View} from 'react-native'; import type {DynamicFormFieldsProps} from './DynamicFormFields'; -type DynamicFormFieldRowProps = Pick & +type DynamicFormFieldRowProps = Pick & Required> & Pick & { field: DynamicFormField; @@ -42,6 +42,8 @@ function DynamicFormFieldRow({ shouldHideLabel = false, onRefreshRequirements, onOpenListItemEditor, + onListItemsChange, + isSensitive = false, renderFields, }: DynamicFormFieldRowProps) { const {translate} = useLocalize(); @@ -74,10 +76,10 @@ function DynamicFormFieldRow({ const shouldRefreshOnChange = !!field.refreshOnChange && !isTypedField; const {input, isMenuRow, labelAbove, showsDescription} = renderDynamicField( field, - {values, translate, styles, currency, maxFileSize, isLoneField, onOpenListItemEditor, renderFields}, + {values, translate, styles, currency, maxFileSize, isLoneField, onOpenListItemEditor, onListItemsChange, isSensitive, renderFields}, { inputID: field.key, - shouldSaveDraft: !isSensitiveField(field), + shouldSaveDraft: !isSensitive && !isSensitiveField(field), forwardedFSClass: CONST.FULLSTORY.CLASS.MASK, onValueChange: shouldRefreshOnChange ? (value, key) => onRefreshRequirements?.(key, value) : undefined, onBlur: shouldRefreshOnBlur ? refreshRequirements : undefined, diff --git a/src/components/DynamicForm/components/DynamicFormFields.tsx b/src/components/DynamicForm/components/DynamicFormFields.tsx index ab640e9bf146..f056bc678a7d 100644 --- a/src/components/DynamicForm/components/DynamicFormFields.tsx +++ b/src/components/DynamicForm/components/DynamicFormFields.tsx @@ -5,7 +5,7 @@ import isHeading from '@components/DynamicForm/utils/isHeading'; import type {FormValue} from '@components/Form/types'; import CONST from '@src/CONST'; -import type {DynamicFormContentItem, DynamicFormField} from '@src/types/onyx'; +import type {DynamicFormContentItem, DynamicFormField, DynamicFormListItem} from '@src/types/onyx'; import React from 'react'; @@ -36,6 +36,12 @@ type DynamicFormFieldsProps = { /** Opens the editor page of a list entry, or of a new one without `itemID`. DynamicFormFlow passes it; without it list entries are edited in a modal. */ onOpenListItemEditor?: (listKey: string, itemID?: string) => void; + + /** Called with a list's entries after the user removes one, so the flow can keep a sensitive list in memory up to date */ + onListItemsChange?: (listKey: string, items: DynamicFormListItem[]) => void; + + /** Every answer here is sensitive, as in the editor of an entry of a sensitive list: none is drafted, and each starts from the answer in `values` */ + isSensitive?: boolean; }; /** The inputs of a schema-driven form. Render it inside a FormProvider and validate the fields of the same content with getDynamicFieldErrors. */ @@ -48,6 +54,8 @@ function DynamicFormFields({ maxFileSize, onRefreshRequirements, onOpenListItemEditor, + onListItemsChange, + isSensitive = false, }: DynamicFormFieldsProps) { const visibleContent = getVisibleContent(content, values, allFields); const loneField = getLoneField(getContentFields(visibleContent)); @@ -78,6 +86,8 @@ function DynamicFormFields({ shouldHideLabel={isTitledByPage && item === loneField} onRefreshRequirements={onRefreshRequirements} onOpenListItemEditor={onOpenListItemEditor} + onListItemsChange={onListItemsChange} + isSensitive={isSensitive} renderFields={renderFields} /> ), diff --git a/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx b/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx index 7c47351c6e5d..0c9db40f63e6 100644 --- a/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx +++ b/src/components/DynamicForm/components/DynamicFormFieldsPage.tsx @@ -3,6 +3,7 @@ import getContentFields from '@components/DynamicForm/utils/getContentFields'; import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; import getLocalizedText from '@components/DynamicForm/utils/getLocalizedText'; import getPageTitle from '@components/DynamicForm/utils/getPageTitle'; +import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; import toDynamicFormValues from '@components/DynamicForm/utils/toDynamicFormValues'; import FormProvider from '@components/Form/FormProvider'; import Text from '@components/Text'; @@ -31,6 +32,7 @@ function DynamicFormFieldsPage({ onRefreshRequirements, onPageSubmit, onOpenListItemEditor, + onListItemsChange, }: DynamicFormSubPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -38,7 +40,12 @@ function DynamicFormFieldsPage({ if (!page) { return null; } - const withInputValues = (inputValues: unknown) => ({...values, ...toDynamicFormValues(inputValues)}); + // A sensitive list lives in memory, which the editor page and a removal update, so the form's own copy of it may be stale + const pageKeys = new Set(getContentFields(page.content).map((field) => field.key)); + const memoryListValues = Object.fromEntries( + allFields.filter((field) => field.type === 'list' && isSensitiveField(field) && pageKeys.has(field.key)).map((field) => [field.key, values[field.key]]), + ); + const withInputValues = (inputValues: unknown) => ({...values, ...toDynamicFormValues(inputValues), ...memoryListValues}); return ( ); diff --git a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx index c3d829edad41..52a3d4d0fd1f 100644 --- a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx +++ b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx @@ -1,6 +1,7 @@ import type {DynamicFormSubPageProps} from '@components/DynamicForm/types'; import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; import getLocalizedText from '@components/DynamicForm/utils/getLocalizedText'; +import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; import {getListItems, getListItemSensitiveAnswers, parseListItemPageName} from '@components/DynamicForm/utils/listItems'; import summarizeListItem from '@components/DynamicForm/utils/summarizeListItem'; import toDynamicFormValues from '@components/DynamicForm/utils/toDynamicFormValues'; @@ -29,11 +30,13 @@ function DynamicFormListItemPage({currentPageName, allFields, values, currency, const listField = allFields.find((field): field is DynamicFormListField => field.type === 'list' && field.key === page?.listKey); const item = listField ? getListItems(values[listField.key]).find((candidate) => candidate.id === page?.itemID) : undefined; const [filledPageName, setFilledPageName] = useState(); + // The entries of a sensitive list are never drafted, so the editor starts empty and its inputs take the entry's answers from memory + const isSensitiveList = !!listField && isSensitiveField(listField); // The editor form is shared by every entry, so it is filled with this entry's answers before its inputs mount useEffect(() => { let isCancelled = false; - startListItemEdit(item ?? {}).then(() => { + startListItemEdit(isSensitiveList ? {} : (item ?? {})).then(() => { if (isCancelled) { return; } @@ -42,7 +45,7 @@ function DynamicFormListItemPage({currentPageName, allFields, values, currency, return () => { isCancelled = true; }; - }, [currentPageName, item]); + }, [currentPageName, item, isSensitiveList]); useEffect(() => clearListItemEdit, []); @@ -53,7 +56,8 @@ function DynamicFormListItemPage({currentPageName, allFields, values, currency, return ; } const sensitiveAnswers = item ? getListItemSensitiveAnswers(listField, item.id, values) : {}; - const withInputValues = (inputValues: unknown) => ({...sensitiveAnswers, ...toDynamicFormValues(inputValues)}); + const keptAnswers = isSensitiveList ? {...item, ...sensitiveAnswers} : sensitiveAnswers; + const withInputValues = (inputValues: unknown) => ({...keptAnswers, ...toDynamicFormValues(inputValues)}); const itemLabel = getLocalizedText(translate, listField.itemLabelKey, listField.itemLabel); const addTitle = itemLabel ? translate('dynamicForm.addItem', {item: itemLabel}) : translate('common.add'); const itemTitle = item ? summarizeListItem(item, listField.itemFields, translate).title : ''; @@ -77,6 +81,7 @@ function DynamicFormListItemPage({currentPageName, allFields, values, currency, diff --git a/src/components/DynamicForm/index.tsx b/src/components/DynamicForm/index.tsx index 901aae2671ca..f6a15af66bb6 100644 --- a/src/components/DynamicForm/index.tsx +++ b/src/components/DynamicForm/index.tsx @@ -23,10 +23,11 @@ import DynamicFormConfirmationPage from './components/DynamicFormConfirmationPag import DynamicFormFieldsPage from './components/DynamicFormFieldsPage'; import DynamicFormListItemPage from './components/DynamicFormListItemPage'; import DynamicFormShell from './components/DynamicFormShell'; +import getAnswerKeys from './utils/getAnswerKeys'; import getContentFields from './utils/getContentFields'; import getDynamicFieldErrors from './utils/getDynamicFieldErrors'; import getPageTitle, {isPageShown} from './utils/getPageTitle'; -import getSubmittedAnswers, {getAnswerKeys} from './utils/getSubmittedAnswers'; +import getSubmittedAnswers from './utils/getSubmittedAnswers'; import isSensitiveField from './utils/isSensitiveField'; import isSupportedField from './utils/isSupportedField'; import {getListItemPageName, getListItems, getListItemSensitiveKey, getRemovedListItemSensitiveKeys, parseListItemPageName} from './utils/listItems'; @@ -222,7 +223,7 @@ function DynamicFormFlow({ return; } const id = itemID ?? Str.guid(); - const sensitiveKeys = new Set(listField.itemFields.filter(isSensitiveField).map((field) => field.key)); + const sensitiveKeys = new Set(listField.itemFields.filter(isSensitiveField).flatMap(getAnswerKeys)); const item: DynamicFormListItem = {id}; const sensitiveAnswers: DynamicFormValues = {}; for (const [key, answer] of Object.entries(getSubmittedAnswers(listField.itemFields, itemValues))) { @@ -241,6 +242,19 @@ function DynamicFormFlow({ closeListItemEditor(listKey); }; + /** A sensitive list is kept in memory, so removing an entry is saved right away for the next entry added to build on. A drafted list is saved as it changes. */ + const changeListItems = (listKey: string, items: DynamicFormListItem[]) => { + const listField = listFields.find((field) => field.key === listKey); + if (!listField || !isSensitiveField(listField)) { + return; + } + saveSensitiveAnswers(formID, {[listKey]: items}); + const removedSensitiveKeys = getRemovedListItemSensitiveKeys(listField, {...values, [listKey]: items}); + if (removedSensitiveKeys.length > 0) { + forgetSensitiveAnswers(formID, removedSensitiveKeys); + } + }; + const goBack = () => { if (listItemPage) { closeListItemEditor(listItemPage.listKey); @@ -299,6 +313,7 @@ function DynamicFormFlow({ onPageSubmit={handlePageSubmit} onOpenListItemEditor={openListItemEditor} onListItemSave={saveListItem} + onListItemsChange={changeListItems} confirmationTitle={confirmationTitle} isSubmitting={isSubmitting} submitError={submitError} diff --git a/src/components/DynamicForm/renderDynamicField.tsx b/src/components/DynamicForm/renderDynamicField.tsx index 660bea6794b1..6933b55ced95 100644 --- a/src/components/DynamicForm/renderDynamicField.tsx +++ b/src/components/DynamicForm/renderDynamicField.tsx @@ -21,7 +21,7 @@ const RENDERERS: DynamicFieldRendererMap = { /** The input for one field, and how the renderer lays it out */ function renderDynamicField(field: DynamicFormFieldOfType, context: DynamicFieldContext, inputProps: DynamicFieldInputProps): DynamicFieldInput { const render: DynamicFieldRenderer = RENDERERS[field.type]; - return render(field, context, {...inputProps, defaultValue: getSensitiveDefaultValue(field, context.values)}); + return render(field, context, {...inputProps, defaultValue: getSensitiveDefaultValue(field, context.values, context.isSensitive)}); } export default renderDynamicField; diff --git a/src/components/DynamicForm/renderers/listRenderer.tsx b/src/components/DynamicForm/renderers/listRenderer.tsx index b62da9833ac3..2106f6e37942 100644 --- a/src/components/DynamicForm/renderers/listRenderer.tsx +++ b/src/components/DynamicForm/renderers/listRenderer.tsx @@ -1,14 +1,19 @@ import ListFieldAdapter from '@components/DynamicForm/adapters/ListFieldAdapter'; import getLocalizedText, {getFieldLabel} from '@components/DynamicForm/utils/getLocalizedText'; import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; +import {getListItems} from '@components/DynamicForm/utils/listItems'; import InputWrapper from '@components/Form/InputWrapper'; +import type {DynamicFormListItem} from '@src/types/onyx'; + import React from 'react'; import type {DynamicFieldRenderer} from './types'; -const renderList: DynamicFieldRenderer<'list'> = (field, {translate, isLoneField, onOpenListItemEditor, renderFields}, inputProps) => { +const renderList: DynamicFieldRenderer<'list'> = (field, {translate, isLoneField, values, onOpenListItemEditor, onListItemsChange, renderFields}, inputProps) => { const itemLabel = getLocalizedText(translate, field.itemLabelKey, field.itemLabel); + // A sensitive list is not drafted, so inside the flow its entries come from memory, which the editor page and a removal both update + const isMemoryList = !!onOpenListItemEditor && isSensitiveField(field); return { isMenuRow: true, labelAbove: isLoneField ? undefined : 'heading', @@ -19,6 +24,8 @@ const renderList: DynamicFieldRenderer<'list'> = (field, {translate, isLoneField // Outside the flow sensitive entry answers stay in the list value, which must not reach the draft shouldSaveDraft={inputProps.shouldSaveDraft && (!!onOpenListItemEditor || !field.itemFields.some(isSensitiveField))} valueType="listItems" + value={isMemoryList ? getListItems(values[field.key]) : undefined} + onItemsChange={isMemoryList ? (items: DynamicFormListItem[]) => onListItemsChange?.(field.key, items) : undefined} label={getFieldLabel(field, translate)} itemLabel={itemLabel} itemFields={field.itemFields} diff --git a/src/components/DynamicForm/renderers/types.ts b/src/components/DynamicForm/renderers/types.ts index 470a3babc1d5..298d393a4ed3 100644 --- a/src/components/DynamicForm/renderers/types.ts +++ b/src/components/DynamicForm/renderers/types.ts @@ -11,7 +11,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type {ReactElement, ReactNode} from 'react'; -type DynamicFieldContext = Pick & +type DynamicFieldContext = Pick & Required> & { translate: LocalizedTranslate; styles: ThemeStyles; diff --git a/src/components/DynamicForm/types.ts b/src/components/DynamicForm/types.ts index f695504b62ce..596f229297fd 100644 --- a/src/components/DynamicForm/types.ts +++ b/src/components/DynamicForm/types.ts @@ -75,6 +75,8 @@ type DynamicFormSubPageProps = SubPageProps & onOpenListItemEditor: NonNullable; + onListItemsChange: NonNullable; + /** The last page submits the form, since no confirmation page follows */ isLastPage: boolean; diff --git a/src/components/DynamicForm/utils/getAnswerKeys.ts b/src/components/DynamicForm/utils/getAnswerKeys.ts new file mode 100644 index 000000000000..ff3ee4717b1e --- /dev/null +++ b/src/components/DynamicForm/utils/getAnswerKeys.ts @@ -0,0 +1,17 @@ +import type {DynamicFormField} from '@src/types/onyx'; + +import getAddressInputKeys from './getAddressInputKeys'; + +/** The draft keys a field writes to: its own, plus its address parts or its chosen currency */ +function getAnswerKeys(field: DynamicFormField): string[] { + switch (field.type) { + case 'address': + return Object.values(getAddressInputKeys(field.key)).filter(Boolean); + case 'amount': + return field.currencyKey ? [field.key, field.currencyKey] : [field.key]; + default: + return [field.key]; + } +} + +export default getAnswerKeys; diff --git a/src/components/DynamicForm/utils/getConfirmationItems.ts b/src/components/DynamicForm/utils/getConfirmationItems.ts index d12307c59348..b7819f33cce1 100644 --- a/src/components/DynamicForm/utils/getConfirmationItems.ts +++ b/src/components/DynamicForm/utils/getConfirmationItems.ts @@ -19,15 +19,18 @@ import {getListItems} from './listItems'; import maskSensitiveValue from './maskSensitiveValue'; import summarizeListItem from './summarizeListItem'; -/** One avatar row per list entry, which opens the entry */ +/** One avatar row per list entry, which opens the entry. A readonly list gets plain rows that open nothing. */ function getListItemRows( field: DynamicFormListField, values: DynamicFormValues, translate: LocalizedTranslate, onEditListItem: (listKey: string, itemID: string) => void, ): SummaryGroupRow[] { - return getListItems(values[field.key]).map((item) => { + return getListItems(values[field.key]).map((item): SummaryGroupRow => { const {title, description} = summarizeListItem(item, field.itemFields, translate); + if (field.readonly) { + return {id: `${field.key}-${item.id}`, title, description, shouldShowRightIcon: false, onPress: noop, interactive: false}; + } return { kind: 'avatar', id: `${field.key}-${item.id}`, diff --git a/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts index 8f1fd6ac5d4e..0f78ddf4e6a5 100644 --- a/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts +++ b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts @@ -47,11 +47,15 @@ const ANSWER_GUARDS: AnswerGuards = { list: isListItems, }; -/** Sensitive answers are not drafted, so a page the user comes back to is refilled from the answers kept in memory */ -function getSensitiveDefaultValue(field: DynamicFormFieldOfType, values: DynamicFormValues): DynamicFieldAnswerMap[TType] | undefined { +/** Sensitive answers are not drafted, so a page the user comes back to is refilled from the answers kept in memory. `isInSensitiveForm` treats every field as sensitive. */ +function getSensitiveDefaultValue( + field: DynamicFormFieldOfType, + values: DynamicFormValues, + isInSensitiveForm = false, +): DynamicFieldAnswerMap[TType] | undefined { const answer = values[field.key]; const isAnswer: AnswerGuards[TType] = ANSWER_GUARDS[field.type]; - return isSensitiveField(field) && answer !== undefined && isAnswer(answer) ? answer : undefined; + return (isInSensitiveForm || isSensitiveField(field)) && answer !== undefined && isAnswer(answer) ? answer : undefined; } export default getSensitiveDefaultValue; diff --git a/src/components/DynamicForm/utils/getSubmittedAnswers.ts b/src/components/DynamicForm/utils/getSubmittedAnswers.ts index 932f2c88ea88..47463db3d78c 100644 --- a/src/components/DynamicForm/utils/getSubmittedAnswers.ts +++ b/src/components/DynamicForm/utils/getSubmittedAnswers.ts @@ -2,22 +2,10 @@ import type {DynamicFormValues} from '@components/DynamicForm/types'; import type {DynamicFormField} from '@src/types/onyx'; -import getAddressInputKeys from './getAddressInputKeys'; +import getAnswerKeys from './getAnswerKeys'; import getVisibleFields from './getVisibleFields'; import {getListItems, getListItemSensitiveAnswers} from './listItems'; -/** The draft keys a field writes to: its own, plus its address parts or its chosen currency */ -function getAnswerKeys(field: DynamicFormField): string[] { - switch (field.type) { - case 'address': - return Object.values(getAddressInputKeys(field.key)).filter(Boolean); - case 'amount': - return field.currencyKey ? [field.key, field.currencyKey] : [field.key]; - default: - return [field.key]; - } -} - /** The answers to the fields the user can see, so answers left on fields hidden later are not submitted. List entries get their sensitive answers back from memory. Pass the whole form as `allFields` when `fields` is one page of it. */ function getSubmittedAnswers(fields: DynamicFormField[], values: DynamicFormValues, allFields = fields): DynamicFormValues { const answers: DynamicFormValues = {}; diff --git a/src/components/DynamicForm/utils/listItems.ts b/src/components/DynamicForm/utils/listItems.ts index a4ad3e2fe381..a3d1b8bb87d4 100644 --- a/src/components/DynamicForm/utils/listItems.ts +++ b/src/components/DynamicForm/utils/listItems.ts @@ -2,6 +2,7 @@ import type {DynamicFormValues} from '@components/DynamicForm/types'; import type {DynamicFormListField, DynamicFormListItem} from '@src/types/onyx'; +import getAnswerKeys from './getAnswerKeys'; import isSensitiveField from './isSensitiveField'; const PAGE_NAME_SEPARATOR = '~'; @@ -42,17 +43,20 @@ function getListItems(value: unknown): DynamicFormListItem[] { /** An entry's sensitive answers, keyed by field key, from the answers kept in memory */ function getListItemSensitiveAnswers(field: DynamicFormListField, itemID: string, values: DynamicFormValues): DynamicFormValues { return Object.fromEntries( - field.itemFields.filter(isSensitiveField).flatMap((itemField) => { - const answer = values[getListItemSensitiveKey(field.key, itemID, itemField.key)]; - return answer === undefined ? [] : [[itemField.key, answer]]; - }), + field.itemFields + .filter(isSensitiveField) + .flatMap(getAnswerKeys) + .flatMap((answerKey) => { + const answer = values[getListItemSensitiveKey(field.key, itemID, answerKey)]; + return answer === undefined ? [] : [[answerKey, answer]]; + }), ); } /** Memory keys of sensitive answers whose entry the user has removed from the list */ function getRemovedListItemSensitiveKeys(field: DynamicFormListField, values: DynamicFormValues): string[] { - const sensitiveFields = field.itemFields.filter(isSensitiveField); - const keptKeys = new Set(getListItems(values[field.key]).flatMap((item) => sensitiveFields.map((itemField) => getListItemSensitiveKey(field.key, item.id, itemField.key)))); + const sensitiveKeys = field.itemFields.filter(isSensitiveField).flatMap(getAnswerKeys); + const keptKeys = new Set(getListItems(values[field.key]).flatMap((item) => sensitiveKeys.map((answerKey) => getListItemSensitiveKey(field.key, item.id, answerKey)))); return Object.keys(values).filter((key) => key.startsWith(`${field.key}${PAGE_NAME_SEPARATOR}`) && !keptKeys.has(key)); } diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx index b1670db5b7ce..22ef5a621c0c 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsPageTest.tsx @@ -28,6 +28,7 @@ function renderPage(isLastPage: boolean) { values={{}} onPageSubmit={jest.fn()} onOpenListItemEditor={jest.fn()} + onListItemsChange={jest.fn()} onListItemSave={jest.fn()} confirmationTitle="Confirm" />, diff --git a/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx b/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx index 51108c4b0ec4..444752448001 100644 --- a/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx +++ b/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx @@ -42,7 +42,7 @@ jest.mock('@components/DynamicForm/components/DynamicFormListItemModal', () => ( __esModule: true, default: (props: CapturedModalProps) => mockModal(props), })); -jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string, params?: unknown) => (params === undefined ? key : `${key} ${JSON.stringify(params)}`)}))); jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: mockShowConfirmModal}))); jest.mock('@userActions/DynamicForm', () => ({startListItemEdit: jest.fn(() => Promise.resolve()), clearListItemEdit: jest.fn()})); From 33380e45399876ab3fd1cb122288906b543f3151 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 14 Oct 2026 12:22:01 +0200 Subject: [PATCH 644/781] fix: hide the grip when pointer capture is lost off the heading --- .../columnResize/useColumnResize/index.ts | 16 ++++++++------ .../components/Table/useColumnResizeTest.ts | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/components/Table/columnResize/useColumnResize/index.ts b/src/components/Table/columnResize/useColumnResize/index.ts index 0742b265f914..d7fe4ff77e31 100644 --- a/src/components/Table/columnResize/useColumnResize/index.ts +++ b/src/components/Table/columnResize/useColumnResize/index.ts @@ -70,6 +70,13 @@ function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnW setTableColumnWidth(columnResizingID, drag.columnKey, width); }; + // Releasing or losing capture doesn't reliably fire boundary events, so hit-test the heading, including the handle overhanging it. + const syncGripHover = (event: React.PointerEvent) => { + const isPointerOnHeading = !!event.currentTarget.parentElement?.contains(document.elementFromPoint(event.clientX, event.clientY)); + + setGripHovered(event.currentTarget, isPointerOnHeading); + }; + const handlePointerDown = (columnKey: string, event: React.PointerEvent) => { // Secondary buttons open context menus rather than dragging. if (event.button !== 0) { @@ -117,15 +124,11 @@ function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnW } endDrag(drag); - - // Releasing capture doesn't reliably fire boundary events, so hit-test the heading, including the handle overhanging it. - const isPointerOnHeading = !!event.currentTarget.parentElement?.contains(document.elementFromPoint(event.clientX, event.clientY)); - - setGripHovered(event.currentTarget, isPointerOnHeading); + syncGripHover(event); }; /** Ends a drag whose pointer capture the browser reclaimed. Also fires after a normal pointerup, when it's a no-op. */ - const handleLostPointerCapture = () => { + const handleLostPointerCapture = (event: React.PointerEvent) => { const drag = dragRef.current; if (!drag) { @@ -133,6 +136,7 @@ function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnW } endDrag(drag); + syncGripHover(event); }; // Unmounting mid-drag would otherwise leave the resize cursor on the document and a dangling drag. diff --git a/tests/unit/components/Table/useColumnResizeTest.ts b/tests/unit/components/Table/useColumnResizeTest.ts index 7d72541db0b0..27b83d796596 100644 --- a/tests/unit/components/Table/useColumnResizeTest.ts +++ b/tests/unit/components/Table/useColumnResizeTest.ts @@ -275,6 +275,27 @@ describe('useColumnResize', () => { expect(readGripHover()).toBe('0'); }); + it('hides the grip when the pointer capture is lost off the heading', () => { + // Given a hovered heading whose edge is being dragged off the heading + const {headingElement, handleElement, getHandleProps, readGripHover, mountHandle} = renderColumnResize(); + + mountHandle(); + headingElement.dispatchEvent(new Event('pointerenter')); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 190, clientY: 20})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 400, clientY: 300})); + }); + + // When the browser takes the pointer capture back there, e.g. because the window lost focus + act(() => { + getHandleProps().onLostPointerCapture?.(createPointerEvent(handleElement, {clientX: 400, clientY: 300})); + }); + + // Then the grip hides, the same as a release there, rather than lingering on a heading the pointer isn't over + expect(readGripHover()).toBe('0'); + }); + it('keeps the grip when a drag is released on the heading', () => { // Given a hovered heading whose edge is being dragged const {headingElement, handleElement, getHandleProps, readGripHover, mountHandle} = renderColumnResize(); From 7b166e05245ae9a0de525d33fe5b55004d79120d Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 14 Oct 2026 12:25:42 +0200 Subject: [PATCH 645/781] fix: refresh requirements on list item save and lock readonly list editors --- .../DynamicForm/adapters/ListFieldAdapter.tsx | 3 +- .../components/DynamicFormListItemPage.tsx | 3 +- src/components/DynamicForm/index.tsx | 13 +++++-- .../DynamicForm/DynamicFormFlowTest.tsx | 38 +++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/components/DynamicForm/adapters/ListFieldAdapter.tsx b/src/components/DynamicForm/adapters/ListFieldAdapter.tsx index 239a01b4232f..5398c6f14e3e 100644 --- a/src/components/DynamicForm/adapters/ListFieldAdapter.tsx +++ b/src/components/DynamicForm/adapters/ListFieldAdapter.tsx @@ -1,5 +1,6 @@ import DynamicFormListItemModal from '@components/DynamicForm/components/DynamicFormListItemModal'; import type {DynamicFormValues} from '@components/DynamicForm/types'; +import getAnswerKeys from '@components/DynamicForm/utils/getAnswerKeys'; import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; import summarizeListItem from '@components/DynamicForm/utils/summarizeListItem'; import ListField from '@components/ListField'; @@ -85,7 +86,7 @@ function ListFieldAdapter({ if (editingItem) { modalTitle = editingTitle === '' ? label : editingTitle; } - const sensitiveKeys = new Set(itemFields.filter(isSensitiveField).map((field) => field.key)); + const sensitiveKeys = new Set(itemFields.filter(isSensitiveField).flatMap(getAnswerKeys)); // Sensitive answers are not drafted, so the modal shows them blank and a blank one keeps the stored answer const openModal = (item?: DynamicFormListItem) => { diff --git a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx index 52a3d4d0fd1f..0fa4b279813d 100644 --- a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx +++ b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx @@ -23,7 +23,7 @@ import React, {useEffect, useState} from 'react'; import DynamicFormFields from './DynamicFormFields'; /** The editor of one list entry, or of a new one: the entry's fields and a Save button */ -function DynamicFormListItemPage({currentPageName, allFields, values, currency, maxFileSize, onListItemSave}: DynamicFormSubPageProps) { +function DynamicFormListItemPage({currentPageName, allFields, values, currency, maxFileSize, onRefreshRequirements, onListItemSave}: DynamicFormSubPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const page = parseListItemPageName(currentPageName); @@ -84,6 +84,7 @@ function DynamicFormListItemPage({currentPageName, allFields, values, currency, isSensitive={isSensitiveList} currency={currency} maxFileSize={maxFileSize} + onRefreshRequirements={onRefreshRequirements} /> )} diff --git a/src/components/DynamicForm/index.tsx b/src/components/DynamicForm/index.tsx index f6a15af66bb6..516ce17da001 100644 --- a/src/components/DynamicForm/index.tsx +++ b/src/components/DynamicForm/index.tsx @@ -74,8 +74,10 @@ function DynamicFormFlow({ // useSubPage needs at least one page, so a form without pages gets the confirmation page const hasConfirmationPage = pages.length === 0 || hasConfirmation; - // Entry editors come after the confirmation page and are skipped by Next and Back, so only an entry's Add, Edit or row opens them - const listItemPageNames = listFields.flatMap((field) => [getListItemPageName(field.key), ...getListItems(values[field.key]).map((item) => getListItemPageName(field.key, item.id))]); + // Entry editors come after the confirmation page and are skipped by Next and Back, so only an entry's Add, Edit or row opens them. A readonly list has no editors. + const listItemPageNames = listFields + .filter((field) => !field.readonly) + .flatMap((field) => [getListItemPageName(field.key), ...getListItems(values[field.key]).map((item) => getListItemPageName(field.key, item.id))]); const subPages = [ ...pages.map((page) => ({pageName: page.key, component: DynamicFormFieldsPage})), ...(hasConfirmationPage ? [{pageName: CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY, component: DynamicFormConfirmationPage}] : []), @@ -234,11 +236,16 @@ function DynamicFormFlow({ } } const items = getListItems(values[listKey]); + const savedItems = itemID ? items.map((existing) => (existing.id === id ? item : existing)) : [...items, item]; const saveListAnswer = isSensitiveField(listField) ? saveSensitiveAnswers : saveDraftAnswers; - saveListAnswer(formID, {[listKey]: itemID ? items.map((existing) => (existing.id === id ? item : existing)) : [...items, item]}); + saveListAnswer(formID, {[listKey]: savedItems}); if (Object.keys(sensitiveAnswers).length > 0) { saveSensitiveAnswers(formID, sensitiveAnswers); } + // The editor saves outside the list's input, so a list that asks for fresh requirements on change asks here, as a removal does through its input + if (listField.refreshOnChange) { + onRefreshRequirements?.(listKey, savedItems); + } closeListItemEditor(listKey); }; diff --git a/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx index ed08c1fe9493..0ac0d69d6b7a 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx @@ -93,6 +93,7 @@ type RenderFlowOptions = { isSubmitting?: boolean; shouldReplaceRoute?: boolean; maxFileSize?: number; + onRefreshRequirements?: (inputID: string, value: FormValue) => void; }; async function renderFlow({ @@ -107,6 +108,7 @@ async function renderFlow({ isSubmitting, shouldReplaceRoute, maxFileSize, + onRefreshRequirements, }: RenderFlowOptions = {}) { await Onyx.set(`${FORM_ID}Draft`, draft); await Onyx.set(ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS, {[FORM_ID]: sensitiveAnswers}); @@ -140,6 +142,7 @@ async function renderFlow({ hasConfirmation={hasConfirmation === 'default' ? undefined : hasConfirmation} layout={layout} maxFileSize={maxFileSize} + onRefreshRequirements={onRefreshRequirements} isSubmitting={submitting} /> ); @@ -601,6 +604,41 @@ describe('DynamicFormFlow', () => { expect(getListItems(jest.mocked(saveSensitiveAnswers).mock.lastCall?.[1].directors).map((item) => item.firstName)).toEqual(['John']); }); + it('asks for fresh requirements when the user saves an entry of a list that refreshes them on change', async () => { + // Given the editor of a new director, in a list whose entries decide which questions come next + const refreshingSchema: DynamicFormSchema = { + pages: [ + {key: 'directors', content: [{key: 'directors', type: 'list', required: true, refreshOnChange: true, itemFields: [{key: 'firstName', type: 'text', required: true}]}]}, + ], + }; + const onRefreshRequirements = jest.fn(); + const {pageProps} = await renderFlow({flowSchema: refreshingSchema, draft: {directors: [jane]}, currentPageName: 'directors~new', onRefreshRequirements}); + + // When the user saves the director + pageProps?.onListItemSave('directors', undefined, {firstName: 'John'}); + + // Then the screen gets the updated list, as it does when an entry is removed, so the requirements match the entries + expect(onRefreshRequirements).toHaveBeenCalledTimes(1); + const refreshedItems = getListItems(onRefreshRequirements.mock.lastCall?.[1]); + expect(onRefreshRequirements.mock.lastCall?.[0]).toBe('directors'); + expect(refreshedItems.map((item) => item.firstName)).toEqual(['Jane', 'John']); + }); + + it('gives a readonly list no entry editors', async () => { + // Given a readonly list with one entry + const readonlySchema: DynamicFormSchema = { + pages: [{key: 'directors', content: [{key: 'directors', type: 'list', required: true, readonly: true, itemFields: [{key: 'firstName', type: 'text', required: true}]}]}], + }; + + // When the flow renders + const {subPageOptions} = await renderFlow({flowSchema: readonlySchema, draft: {directors: [jane]}, currentPageName: 'directors'}); + + // Then no editor page exists, so a link to one cannot edit an answer the schema locks + const pageNames = subPageOptions.pages.map((page) => page.pageName); + expect(pageNames).not.toContain('directors~new'); + expect(pageNames).not.toContain('directors~jane'); + }); + it('returns from an entry editor to its list page on back', async () => { // Given the editor of an existing entry const {onBack} = await renderFlow({flowSchema: directorsSchema, draft: {directors: [jane]}, currentPageName: 'directors~jane', pageIndex: 2}); From 393473a976bd5f45ca7c91a31b228ba9933d6118 Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Wed, 14 Oct 2026 10:38:30 +0000 Subject: [PATCH 646/781] Apply Polyglot translations for per diem empty state subtitle Co-authored-by: Eskalifer1 --- src/languages/de.ts | 2 +- src/languages/el.ts | 2 +- src/languages/es.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 6977ac0d07e6..f2dd5c1e4d06 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5102,7 +5102,7 @@ ${amount} für ${merchant} – ${date}`, }, requestEmptyList: { title: 'Noch keine Pauschalbeträge', - subtitle: (workspaceName: string) => `In ${workspaceName} sind keine Pauschalen hinterlegt. Bitten Sie einen Admin, welche hinzuzufügen.`, + subtitle: (workspaceName: string) => `${workspaceName} hat keine Pauschalspesensätze. Bitten Sie eine Adminperson, einige hinzuzufügen.`, adminSubtitle: 'Fügen Sie Pauschalen hinzu, um Ihre Ausgaben zu organisieren.', }, importPerDiemRates: 'Tagespauschalen importieren', diff --git a/src/languages/el.ts b/src/languages/el.ts index 1f0035116c39..0eca4dfef941 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -5178,7 +5178,7 @@ ${amount} για ${merchant} - ${date}`, editCurrencySubtitle: (destination: string) => `Η ενημέρωση αυτού του νομίσματος θα το αλλάξει για όλες τις υποτιμές ημερήσιας αποζημίωσης ${destination}.`, requestEmptyList: { title: 'Δεν υπάρχουν ακόμη ημερήσια επιδόματα', - subtitle: (workspaceName: string) => `Ο χώρος εργασίας ${workspaceName} δεν έχει ημερήσια επιδόματα. Ζητήστε από έναν διαχειριστή να προσθέσει κάποια.`, + subtitle: (workspaceName: string) => `Το ${workspaceName} δεν έχει ημερήσιες αποζημιώσεις. Ζητήστε από έναν διαχειριστή να προσθέσει μερικές.`, adminSubtitle: 'Προσθέστε ημερήσια επιδόματα για να οργανώσετε τις δαπάνες σας.', }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index c18bf5f9c1ef..fdb3b1140623 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5024,7 +5024,7 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Aún no hay tasas de Per diem', - subtitle: (workspaceName: string) => `${workspaceName} no tiene ninguna tasa de per diem. Pide a un administrador que añada algunas.`, + subtitle: (workspaceName: string) => `${workspaceName} no tiene ninguna tasa de per diem. Pídele a un administrador que añada alguna.`, adminSubtitle: 'Añade tasas de per diem para organizar tus gastos.', }, importPerDiemRates: 'Importar tasas de per diem', diff --git a/src/languages/it.ts b/src/languages/it.ts index a65598777d79..0e76212246a1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5070,7 +5070,7 @@ ${amount} per ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ancuna nessuna tariffa di diaria', - subtitle: (workspaceName: string) => `${workspaceName} non ha nessuna diaria. Chiedi a un amministratore di aggiungerne.`, + subtitle: (workspaceName: string) => `${workspaceName} non ha nessuna tariffa di diaria. Chiedi a un amministratore di aggiungerne.`, adminSubtitle: 'Aggiungi le tariffe giornaliere per organizzare le tue spese.', }, importPerDiemRates: 'Importa tariffe di diaria', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index f69033f53987..2f1aaaca7679 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5033,7 +5033,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, requestEmptyList: { title: '日当レートがまだありません', - subtitle: (workspaceName: string) => `${workspaceName}には日当レートがありません。管理者に追加してもらってください。`, + subtitle: (workspaceName: string) => `${workspaceName} には日当レートがありません。管理者に追加してもらってください。`, adminSubtitle: '日当額を追加して、支出を整理しましょう。', }, importPerDiemRates: '日当レートをインポート', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index bb58da78cc90..489b0b6e0a64 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5070,7 +5070,7 @@ ${amount} voor ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nog geen dagvergoedingen', - subtitle: (workspaceName: string) => `${workspaceName} heeft geen dagvergoedingen. Vraag een admin om er een paar toe te voegen.`, + subtitle: (workspaceName: string) => `${workspaceName} heeft geen dagvergoedingen. Vraag een beheerder om er een paar toe te voegen.`, adminSubtitle: 'Voeg dagvergoedingen toe om je uitgaven te organiseren.', }, importPerDiemRates: 'Daggeldtarieven importeren', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 846113b145d5..6b1bdab5f0ae 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5102,7 +5102,7 @@ ${amount} dla ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nie ma jeszcze stawek diet', - subtitle: (workspaceName: string) => `W ${workspaceName} nie ma żadnych stawek diet. Poproś administratora o dodanie kilku.`, + subtitle: (workspaceName: string) => `${workspaceName} nie ma żadnych stawek diet. Poproś administratora, żeby je dodał.`, adminSubtitle: 'Dodaj stawki diet, żeby uporządkować swoje wydatki.', }, importPerDiemRates: 'Zaimportuj stawki diet', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ff169ca455ff..a6faabf020fe 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5062,7 +5062,7 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ainda não há diárias', - subtitle: (workspaceName: string) => `${workspaceName} não tem nenhuma diária definida. Peça a um administrador para adicionar algumas.`, + subtitle: (workspaceName: string) => `${workspaceName} não tem nenhuma taxa de diária. Peça para um administrador adicionar algumas.`, adminSubtitle: 'Adicione diárias para organizar seus gastos.', }, importPerDiemRates: 'Importar tarifas de diária', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c37a361088d2..6c07c57cbb39 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4919,7 +4919,7 @@ ${amount},商户:${merchant} - 日期:${date}`, }, requestEmptyList: { title: '尚未设置每日津贴费率', - subtitle: (workspaceName: string) => `${workspaceName}尚未设置任何每日津贴费率,请联系管理员添加。`, + subtitle: (workspaceName: string) => `${workspaceName} 没有任何日津贴标准。请联系管理员添加一些。`, adminSubtitle: '添加每日津贴标准,以整理您的支出。', }, importPerDiemRates: '导入每日津贴费率', From cc7cc872b0be4ae2f12713ad76ac8910692e68c4 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 14 Oct 2026 17:38:37 +0700 Subject: [PATCH 647/781] add back button --- src/CONST/index.ts | 3 +++ .../CategoryPicker/CategoryPickerModal.tsx | 24 ++++++++++++++++--- .../components/CategoryPickerModalTest.tsx | 21 +++++++++++++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index eb9d84449fec..94963ddbb7ab 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9966,6 +9966,9 @@ const CONST = { GETTING_STARTED_ROW: 'HomePage-GettingStartedRow', GETTING_STARTED_FOOTER_HELP: 'HomePage-GettingStartedFooterHelp', }, + CATEGORY_PICKER_MODAL: { + BACK_BUTTON: 'CategoryPickerModal-BackButton', + }, CALENDAR_PICKER: { YEAR_PICKER: 'CalendarPicker-YearPicker', MONTH_PICKER: 'CalendarPicker-MonthPicker', diff --git a/src/components/CategoryPicker/CategoryPickerModal.tsx b/src/components/CategoryPicker/CategoryPickerModal.tsx index 0c3355070cb6..de368d0bef2d 100644 --- a/src/components/CategoryPicker/CategoryPickerModal.tsx +++ b/src/components/CategoryPicker/CategoryPickerModal.tsx @@ -63,7 +63,7 @@ type CategoryPickerModalProps = { /** Whether the pop-over shrinks to the height its list needs, treating `popoverHeight` as a ceiling. */ shouldFitContentHeight?: boolean; - /** Shows a header with an add button, for users who can add a category to the workspace */ + /** Shows a header with a back button and an add button, for users who can add a category to the workspace */ onAddCategory?: () => void; /** Sentry label for the add button, so each caller's presses are attributed to it */ @@ -92,7 +92,7 @@ function CategoryPickerModal({ const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Plus']); + const icons = useMemoizedLazyExpensifyIcons(['BackArrow', 'Plus']); const StyleUtils = useStyleUtils(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- must match PopoverWithMeasuredContent's dock decision (bottom-docked only when isSmallScreenWidth) const {isSmallScreenWidth} = useResponsiveLayout(); @@ -161,7 +161,25 @@ function CategoryPickerModal({ {!!onAddCategory && ( - {translate('common.category')} + + + + + + + {translate('common.category')} + { mockKeyboardHeight = 0; }); - const renderSheet = ({onAddCategory, shouldFitContentHeight = false}: {onAddCategory?: () => void; shouldFitContentHeight?: boolean} = {}) => + const renderSheet = ({onAddCategory, onClose = jest.fn(), shouldFitContentHeight = false}: {onAddCategory?: () => void; onClose?: () => void; shouldFitContentHeight?: boolean} = {}) => render( { expect(onAddCategory).toHaveBeenCalledTimes(1); }); + it('closes the pop-over from the back button in its header', () => { + // Given the pop-over showing its header, for a user who can add categories + const onClose = jest.fn(); + const onAddCategory = jest.fn(); + renderSheet({onAddCategory, onClose}); + + // When they press the back button on the top left of the header + fireEvent.press(screen.getByLabelText('common.back')); + + // Then the pop-over closes without starting the add-category flow, like the full page's back button leaves the step + expect(onClose).toHaveBeenCalledTimes(1); + expect(onAddCategory).not.toHaveBeenCalled(); + }); + it('shows no header for users who cannot create a category', () => { // Given a user who cannot add categories, e.g. a member or a workspace synced with an accounting integration // When the pop-over opens renderSheet(); - // Then there is no add button or header, matching the full page that hides its "+" for them + // Then there is no back button, add button or header, matching the full page that hides its "+" for them expect(screen.queryByLabelText('workspace.categories.addCategory')).toBeNull(); + expect(screen.queryByLabelText('common.back')).toBeNull(); expect(screen.queryByText('common.category')).toBeNull(); }); From dac27314ff1e7dbb8e3ebf5f0ab6d0f63a31d851 Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Wed, 14 Oct 2026 10:48:16 +0000 Subject: [PATCH 648/781] Fall back to "This workspace" in per diem empty state when the workspace name is missing Co-authored-by: Eskalifer1 --- src/languages/de.ts | 5 ++++- src/languages/el.ts | 5 ++++- src/languages/en.ts | 5 ++++- src/languages/es.ts | 5 ++++- src/languages/fr.ts | 5 ++++- src/languages/it.ts | 5 ++++- src/languages/ja.ts | 5 ++++- src/languages/nl.ts | 5 ++++- src/languages/pl.ts | 5 ++++- src/languages/pt-BR.ts | 5 ++++- src/languages/zh-hans.ts | 2 +- .../iou/request/step/DynamicIOURequestStepDestination.tsx | 2 +- 12 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index f2dd5c1e4d06..74ca7bd0b0e8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5102,7 +5102,10 @@ ${amount} für ${merchant} – ${date}`, }, requestEmptyList: { title: 'Noch keine Pauschalbeträge', - subtitle: (workspaceName: string) => `${workspaceName} hat keine Pauschalspesensätze. Bitten Sie eine Adminperson, einige hinzuzufügen.`, + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} hat keine Pauschalspesensätze. Bitten Sie eine Adminperson, einige hinzuzufügen.` + : 'Dieser Workspace hat keine Pauschalspesensätze. Bitten Sie eine Adminperson, einige hinzuzufügen.', adminSubtitle: 'Fügen Sie Pauschalen hinzu, um Ihre Ausgaben zu organisieren.', }, importPerDiemRates: 'Tagespauschalen importieren', diff --git a/src/languages/el.ts b/src/languages/el.ts index 0eca4dfef941..103cf3c0f1d1 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -5178,7 +5178,10 @@ ${amount} για ${merchant} - ${date}`, editCurrencySubtitle: (destination: string) => `Η ενημέρωση αυτού του νομίσματος θα το αλλάξει για όλες τις υποτιμές ημερήσιας αποζημίωσης ${destination}.`, requestEmptyList: { title: 'Δεν υπάρχουν ακόμη ημερήσια επιδόματα', - subtitle: (workspaceName: string) => `Το ${workspaceName} δεν έχει ημερήσιες αποζημιώσεις. Ζητήστε από έναν διαχειριστή να προσθέσει μερικές.`, + subtitle: (workspaceName?: string) => + workspaceName + ? `Το ${workspaceName} δεν έχει ημερήσιες αποζημιώσεις. Ζητήστε από έναν διαχειριστή να προσθέσει μερικές.` + : 'Αυτός ο χώρος εργασίας δεν έχει ημερήσιες αποζημιώσεις. Ζητήστε από έναν διαχειριστή να προσθέσει μερικές.', adminSubtitle: 'Προσθέστε ημερήσια επιδόματα για να οργανώσετε τις δαπάνες σας.', }, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 187bf3e78651..18e0ce398a93 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5283,7 +5283,10 @@ const translations = { }, requestEmptyList: { title: 'No per diem rates yet', - subtitle: (workspaceName: string) => `${workspaceName} doesn't have any per diem rates. Ask an admin to add some.`, + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} doesn't have any per diem rates. Ask an admin to add some.` + : "This workspace doesn't have any per diem rates. Ask an admin to add some.", adminSubtitle: 'Add per diem rates to organize your spend.', }, importPerDiemRates: 'Import per diem rates', diff --git a/src/languages/es.ts b/src/languages/es.ts index fdb3b1140623..a5d4e03879a2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5024,7 +5024,10 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Aún no hay tasas de Per diem', - subtitle: (workspaceName: string) => `${workspaceName} no tiene ninguna tasa de per diem. Pídele a un administrador que añada alguna.`, + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} no tiene ninguna tasa de per diem. Pídele a un administrador que añada alguna.` + : 'Este espacio de trabajo no tiene ninguna tasa de per diem. Pídele a un administrador que añada alguna.', adminSubtitle: 'Añade tasas de per diem para organizar tus gastos.', }, importPerDiemRates: 'Importar tasas de per diem', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7290c8da420b..c6a852afddd0 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5108,7 +5108,10 @@ ${amount} pour ${merchant} - ${date}`, }, requestEmptyList: { title: 'Aucun taux de per diem pour le moment', - subtitle: (workspaceName: string) => `${workspaceName} n’a aucun taux de per diem. Demandez à un administrateur d’en ajouter.`, + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} n’a aucun taux de per diem. Demandez à un administrateur d’en ajouter.` + : 'Cet espace de travail n’a aucun taux de per diem. Demandez à un administrateur d’en ajouter.', adminSubtitle: 'Ajoutez des taux de per diem pour organiser vos dépenses.', }, importPerDiemRates: 'Importer des taux de per diem', diff --git a/src/languages/it.ts b/src/languages/it.ts index 0e76212246a1..98ea2bfee26a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5070,7 +5070,10 @@ ${amount} per ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ancuna nessuna tariffa di diaria', - subtitle: (workspaceName: string) => `${workspaceName} non ha nessuna tariffa di diaria. Chiedi a un amministratore di aggiungerne.`, + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} non ha nessuna tariffa di diaria. Chiedi a un amministratore di aggiungerne.` + : 'Questo spazio di lavoro non ha nessuna tariffa di diaria. Chiedi a un amministratore di aggiungerne.', adminSubtitle: 'Aggiungi le tariffe giornaliere per organizzare le tue spese.', }, importPerDiemRates: 'Importa tariffe di diaria', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 2f1aaaca7679..aade9c612acc 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5033,7 +5033,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, requestEmptyList: { title: '日当レートがまだありません', - subtitle: (workspaceName: string) => `${workspaceName} には日当レートがありません。管理者に追加してもらってください。`, + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} には日当レートがありません。管理者に追加してもらってください。` + : 'このワークスペースには日当レートがありません。管理者に追加してもらってください。', adminSubtitle: '日当額を追加して、支出を整理しましょう。', }, importPerDiemRates: '日当レートをインポート', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 489b0b6e0a64..212c24f6996a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5070,7 +5070,10 @@ ${amount} voor ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nog geen dagvergoedingen', - subtitle: (workspaceName: string) => `${workspaceName} heeft geen dagvergoedingen. Vraag een beheerder om er een paar toe te voegen.`, + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} heeft geen dagvergoedingen. Vraag een beheerder om er een paar toe te voegen.` + : 'Deze workspace heeft geen dagvergoedingen. Vraag een beheerder om er een paar toe te voegen.', adminSubtitle: 'Voeg dagvergoedingen toe om je uitgaven te organiseren.', }, importPerDiemRates: 'Daggeldtarieven importeren', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 6b1bdab5f0ae..b5ffe2b9ff20 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5102,7 +5102,10 @@ ${amount} dla ${merchant} - ${date}`, }, requestEmptyList: { title: 'Nie ma jeszcze stawek diet', - subtitle: (workspaceName: string) => `${workspaceName} nie ma żadnych stawek diet. Poproś administratora, żeby je dodał.`, + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} nie ma żadnych stawek diet. Poproś administratora, żeby je dodał.` + : 'Ten obszar roboczy nie ma żadnych stawek diet. Poproś administratora, żeby je dodał.', adminSubtitle: 'Dodaj stawki diet, żeby uporządkować swoje wydatki.', }, importPerDiemRates: 'Zaimportuj stawki diet', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a6faabf020fe..57e55a7a7b9c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5062,7 +5062,10 @@ ${amount} para ${merchant} - ${date}`, }, requestEmptyList: { title: 'Ainda não há diárias', - subtitle: (workspaceName: string) => `${workspaceName} não tem nenhuma taxa de diária. Peça para um administrador adicionar algumas.`, + subtitle: (workspaceName?: string) => + workspaceName + ? `${workspaceName} não tem nenhuma taxa de diária. Peça para um administrador adicionar algumas.` + : 'Este espaço de trabalho não tem nenhuma taxa de diária. Peça para um administrador adicionar algumas.', adminSubtitle: 'Adicione diárias para organizar seus gastos.', }, importPerDiemRates: 'Importar tarifas de diária', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 6c07c57cbb39..ebf5d993329c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4919,7 +4919,7 @@ ${amount},商户:${merchant} - 日期:${date}`, }, requestEmptyList: { title: '尚未设置每日津贴费率', - subtitle: (workspaceName: string) => `${workspaceName} 没有任何日津贴标准。请联系管理员添加一些。`, + subtitle: (workspaceName?: string) => (workspaceName ? `${workspaceName} 没有任何日津贴标准。请联系管理员添加一些。` : '此工作区没有任何日津贴标准。请联系管理员添加一些。'), adminSubtitle: '添加每日津贴标准,以整理您的支出。', }, importPerDiemRates: '导入每日津贴费率', diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx index de9515f33d06..e62fbb6c6b76 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx @@ -256,7 +256,7 @@ function DynamicIOURequestStepDestination({ subtitle={ isPolicyAdmin(policy) ? translate('workspace.perDiem.requestEmptyList.adminSubtitle') - : translate('workspace.perDiem.requestEmptyList.subtitle', policy?.name ?? '') + : translate('workspace.perDiem.requestEmptyList.subtitle', policy?.name) } containerStyle={[styles.flex1, styles.justifyContentCenter]} /> From 144afd3a167cdd566e420c48091a7a5f48fbd2d7 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 14 Oct 2026 12:48:38 +0200 Subject: [PATCH 649/781] parrot --- src/languages/de.ts | 14 +++++++------- src/languages/el.ts | 14 +++++++------- src/languages/es.ts | 12 ++++++------ src/languages/fr.ts | 26 +++++++++++++------------- src/languages/it.ts | 10 +++++----- src/languages/ja.ts | 16 ++++++++-------- src/languages/nl.ts | 20 ++++++++++---------- src/languages/pl.ts | 12 ++++++------ src/languages/pt-BR.ts | 12 ++++++------ src/languages/zh-hans.ts | 18 +++++++++--------- 10 files changed, 77 insertions(+), 77 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index fd9233fdc3d6..fc734d92f67b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7892,18 +7892,18 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, }, officeLocations: { title: 'Bürostandorte', - subtitle: 'Fügen Sie Bürostandorte hinzu, um sie Mitgliedern für Dinge wie Entfernungserfassung zuzuweisen.', + subtitle: 'Fügen Sie Bürostandorte hinzu, die Sie Mitgliedern für Dinge wie die Entfernungserfassung zuweisen können.', addOfficeLocation: 'Bürostandort hinzufügen', - editOfficeLocation: 'Bürostandort bearbeiten', + editOfficeLocation: 'Standort des Büros bearbeiten', locationName: 'Standortname', locationNameHint: 'Wählen Sie einen Standort-Spitznamen, wenn Sie mehrere Bürostandorte hinzufügen', defaultName: ({officeNumber}: {officeNumber: number}) => `Bürostandort ${officeNumber}`, setAsPrimary: 'Als primär festlegen', primary: 'Primär', deleteOfficeLocation: 'Bürostandort löschen?', - deleteOfficeLocationConfirmation: 'Möchten Sie diesen Bürostandort wirklich löschen? Mitglieder werden dem primären Büro neu zugewiesen.', - existingOfficeLocationError: 'Ein Bürostandort mit diesem Namen ist bereits vorhanden', - setCompanyAddressAsPrimaryConfirmation: 'Die Firmenadresse als Hauptbüro festlegen?', + deleteOfficeLocationConfirmation: 'Möchten Sie diesen Bürostandort wirklich löschen? Mitglieder werden dem Hauptbüro neu zugewiesen.', + existingOfficeLocationError: 'Ein Bürostandort mit diesem Namen existiert bereits', + setCompanyAddressAsPrimaryConfirmation: 'Unternehmensadresse als Hauptbüro festlegen?', }, bankAccount: { continueWithSetup: 'Einrichtung fortsetzen', @@ -9734,10 +9734,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `„${prohibitedExpense}“ aus verbotenen Ausgaben entfernt`, officeLocation: { added: ({name, address}: {name: string; address: string}) => `hat den Bürostandort „${name}“ (${address}) hinzugefügt`, - renamed: ({oldName, newName}: {oldName: string; newName: string}) => `Bürostandort „${oldName}“ in „${newName}“ umbenannt`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `hat den Bürostandort „${oldName}“ in „${newName}“ umbenannt`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `hat die Adresse des Bürostandorts „${name}“ in „${newAddress}“ geändert (zuvor „${oldAddress}“)`, - setAsPrimary: ({name}: {name: string}) => `setzen Sie den Bürostandort „${name}“ als primär`, + setAsPrimary: ({name}: {name: string}) => `Legen Sie den Bürositz „${name}“ als primär fest`, setCompanyAddressAsPrimary: 'Firmenadresse als primär festlegen', removed: ({name}: {name: string}) => `hat den Bürostandort „${name}“ entfernt`, }, diff --git a/src/languages/el.ts b/src/languages/el.ts index 0eb68e7f8893..203154cb82ca 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8192,18 +8192,18 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, officeLocations: { title: 'Τοποθεσίες γραφείων', - subtitle: 'Προσθέστε τοποθεσίες γραφείων για να τις αντιστοιχίζετε σε μέλη, π.χ. για την παρακολούθηση αποστάσεων.', + subtitle: 'Προσθέστε τοποθεσίες γραφείων για να τις αντιστοιχίσετε στα μέλη για λειτουργίες όπως η παρακολούθηση αποστάσεων.', addOfficeLocation: 'Προσθήκη τοποθεσίας γραφείου', editOfficeLocation: 'Επεξεργασία τοποθεσίας γραφείου', locationName: 'Όνομα τοποθεσίας', - locationNameHint: 'Επιλέξτε ένα ψευδώνυμο τοποθεσίας αν προσθέτετε πολλές τοποθεσίες γραφείων', + locationNameHint: 'Επιλέξτε ένα ψευδώνυμο τοποθεσίας αν προσθέτετε πολλές τοποθεσίες γραφείου', defaultName: ({officeNumber}: {officeNumber: number}) => `Τοποθεσία γραφείου ${officeNumber}`, setAsPrimary: 'Ορισμός ως κύριο', primary: 'Κύριο', - deleteOfficeLocation: 'Διαγραφή τοποθεσίας γραφείου;', - deleteOfficeLocationConfirmation: 'Θέλετε πραγματικά να διαγράψετε αυτήν την τοποθεσία γραφείου; Τα μέλη θα ανατεθούν ξανά στο κύριο γραφείο.', + deleteOfficeLocation: 'Να διαγραφεί η τοποθεσία γραφείου;', + deleteOfficeLocationConfirmation: 'Θέλετε σίγουρα να διαγράψετε αυτήν την τοποθεσία γραφείου; Τα μέλη θα επαναχωριστούν στο κύριο γραφείο.', existingOfficeLocationError: 'Υπάρχει ήδη τοποθεσία γραφείου με αυτό το όνομα', - setCompanyAddressAsPrimaryConfirmation: 'Να οριστεί η εταιρική διεύθυνση ως κύριο γραφείο;', + setCompanyAddressAsPrimaryConfirmation: 'Να οριστεί η διεύθυνση της εταιρείας ως κύριο γραφείο;', }, bankAccount: { continueWithSetup: 'Συνεχίστε τη ρύθμιση', @@ -9856,11 +9856,11 @@ ${reportName}`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `αφαιρέσατε το «${prohibitedExpense}» από τις απαγορευμένες δαπάνες`, officeLocation: { added: ({name, address}: {name: string; address: string}) => `πρόσθεσε την τοποθεσία γραφείου «${name}» (${address})`, - renamed: ({oldName, newName}: {oldName: string; newName: string}) => `μετονόμασε την τοποθεσία γραφείου «${oldName}» σε «${newName}»`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `μετονομάστηκε η τοποθεσία γραφείου «${oldName}» σε «${newName}»`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `άλλαξε τη διεύθυνση της τοποθεσίας γραφείου «${name}» σε «${newAddress}» (προηγουμένως «${oldAddress}»)`, setAsPrimary: ({name}: {name: string}) => `ορίστε την τοποθεσία γραφείου «${name}» ως κύρια`, - setCompanyAddressAsPrimary: 'ορίστε την εταιρική διεύθυνση ως κύρια', + setCompanyAddressAsPrimary: 'ορίστε τη διεύθυνση της εταιρείας ως κύρια', removed: ({name}: {name: string}) => `αφαίρεσε την τοποθεσία γραφείου «${name}»`, }, workArrangement: { diff --git a/src/languages/es.ts b/src/languages/es.ts index b52138eabe0f..fec3edf7a208 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7915,15 +7915,15 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, officeLocations: { title: 'Ubicaciones de oficinas', subtitle: 'Añade ubicaciones de oficina para asignarlas a miembros para cosas como el seguimiento de distancias.', - addOfficeLocation: 'Añade ubicación de oficina', + addOfficeLocation: 'Añadir ubicación de la oficina', editOfficeLocation: 'Editar ubicación de la oficina', locationName: 'Nombre de la ubicación', - locationNameHint: 'Elige un nombre para la ubicación si estás añadiendo varias oficinas', + locationNameHint: 'Elige un nombre abreviado para la ubicación si estás añadiendo varias oficinas', defaultName: ({officeNumber}: {officeNumber: number}) => `Ubicación de la oficina ${officeNumber}`, setAsPrimary: 'Establecer como principal', primary: 'Principal', - deleteOfficeLocation: '¿Eliminar ubicación de oficina?', - deleteOfficeLocationConfirmation: '¿De verdad quieres eliminar esta ubicación de oficina? Los miembros se reasignarán a la oficina principal.', + deleteOfficeLocation: '¿Eliminar la ubicación de la oficina?', + deleteOfficeLocationConfirmation: '¿Seguro que quieres eliminar esta ubicación de oficina? Los miembros serán reasignados a la oficina principal.', existingOfficeLocationError: 'Ya existe una ubicación de oficina con este nombre', setCompanyAddressAsPrimaryConfirmation: '¿Establecer la dirección de la empresa como oficina principal?', }, @@ -9502,11 +9502,11 @@ ${reportName}`, addedProhibitedExpense: ({prohibitedExpense}) => `añadió "${prohibitedExpense}" a los gastos prohibidos`, removedProhibitedExpense: ({prohibitedExpense}) => `eliminó "${prohibitedExpense}" de los gastos prohibidos`, officeLocation: { - added: ({name, address}: {name: string; address: string}) => `añadió la ubicación de oficina «${name}» (${address})`, + added: ({name, address}: {name: string; address: string}) => `añadió la ubicación de la oficina «${name}» (${address})`, renamed: ({oldName, newName}: {oldName: string; newName: string}) => `cambió el nombre de la ubicación de la oficina de "${oldName}" a "${newName}"`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => `cambió la dirección de la oficina "${name}" a "${newAddress}" (previamente "${oldAddress}")`, - setAsPrimary: ({name}: {name: string}) => `establecer la ubicación de oficina "${name}" como principal`, + setAsPrimary: ({name}: {name: string}) => `establecer la ubicación de la oficina «${name}» como principal`, setCompanyAddressAsPrimary: 'establecer la dirección de la empresa como principal', removed: ({name}: {name: string}) => `eliminó la ubicación de la oficina «${name}»`, }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2918d9f09fa4..df1829f81628 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7912,19 +7912,19 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, policy: 'Politique de dépenses', }, officeLocations: { - title: 'Sites de bureaux', - subtitle: 'Ajoutez des bureaux à assigner aux membres pour des éléments comme le suivi des distances.', + title: 'Sites de bureau', + subtitle: 'Ajoutez des bureaux pour les assigner aux membres, par exemple pour le suivi des distances.', addOfficeLocation: 'Ajouter un lieu de travail', - editOfficeLocation: 'Modifier l’emplacement du bureau', + editOfficeLocation: 'Modifier le lieu de travail', locationName: 'Nom du lieu', - locationNameHint: 'Choisissez un nom personnalisé pour l’emplacement si vous ajoutez plusieurs bureaux', - defaultName: ({officeNumber}: {officeNumber: number}) => `Emplacement du bureau ${officeNumber}`, + locationNameHint: 'Choisissez un surnom de lieu si vous ajoutez plusieurs sites de bureau', + defaultName: ({officeNumber}: {officeNumber: number}) => `Bureau ${officeNumber}`, setAsPrimary: 'Définir comme principal', primary: 'Principal', - deleteOfficeLocation: 'Supprimer le bureau ?', - deleteOfficeLocationConfirmation: 'Voulez-vous vraiment supprimer ce bureau ? Les membres seront réaffectés au bureau principal.', - existingOfficeLocationError: 'Un site de bureau portant ce nom existe déjà', - setCompanyAddressAsPrimaryConfirmation: "Définir l'adresse de l'entreprise comme bureau principal ?", + deleteOfficeLocation: 'Supprimer le site ?', + deleteOfficeLocationConfirmation: 'Voulez-vous vraiment supprimer ce bureau ? Les membres seront réassignés au bureau principal.', + existingOfficeLocationError: 'Un bureau portant ce nom existe déjà', + setCompanyAddressAsPrimaryConfirmation: 'Définir l’adresse de l’entreprise comme bureau principal ?', }, bankAccount: { continueWithSetup: 'Continuer la configuration', @@ -9756,12 +9756,12 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `a supprimé « ${prohibitedExpense} » des dépenses interdites`, officeLocation: { added: ({name, address}: {name: string; address: string}) => `a ajouté le lieu de bureau « ${name} » (${address})`, - renamed: ({oldName, newName}: {oldName: string; newName: string}) => `a renommé le bureau « ${oldName} » en « ${newName} »`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `a renommé le lieu de bureau « ${oldName} » en « ${newName} »`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => - `a modifié l’adresse de l’emplacement de bureau « ${name} » pour « ${newAddress} » (auparavant « ${oldAddress} »)`, + `a modifié l’adresse du bureau « ${name} » en « ${newAddress} » (auparavant « ${oldAddress} »)`, setAsPrimary: ({name}: {name: string}) => `définir le lieu de travail « ${name} » comme principal`, - setCompanyAddressAsPrimary: "définir l'adresse de l'entreprise comme principale", - removed: ({name}: {name: string}) => `a supprimé le site "${name}"`, + setCompanyAddressAsPrimary: 'définir l’adresse de l’entreprise comme principale', + removed: ({name}: {name: string}) => `a supprimé le lieu de bureau « ${name} »`, }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `définir l’organisation de travail par défaut sur ${arrangement}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 0f28a0011e5c..4acf1be0d05a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7847,15 +7847,15 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, }, officeLocations: { title: 'Sedi degli uffici', - subtitle: 'Aggiungi sedi dell’ufficio da assegnare ai membri per attività come il tracciamento delle distanze.', + subtitle: 'Aggiungi sedi dell’ufficio da assegnare ai membri per attività come il monitoraggio delle distanze.', addOfficeLocation: 'Aggiungi sede dell’ufficio', editOfficeLocation: 'Modifica sede ufficio', locationName: 'Nome località', locationNameHint: 'Scegli un nome abbreviato per la sede se stai aggiungendo più sedi di ufficio', - defaultName: ({officeNumber}: {officeNumber: number}) => `Sede ufficio ${officeNumber}`, + defaultName: ({officeNumber}: {officeNumber: number}) => `Sede ${officeNumber}`, setAsPrimary: 'Imposta come principale', - primary: 'Primario', - deleteOfficeLocation: 'Eliminare sede ufficio?', + primary: 'Principale', + deleteOfficeLocation: 'Eliminare la sede?', deleteOfficeLocationConfirmation: 'Vuoi davvero eliminare questa sede? I membri verranno riassegnati alla sede principale.', existingOfficeLocationError: 'Esiste già una sede con questo nome', setCompanyAddressAsPrimaryConfirmation: 'Impostare l’indirizzo dell’azienda come sede principale?', @@ -9683,7 +9683,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, added: ({name, address}: {name: string; address: string}) => `ha aggiunto la sede "${name}" (${address})`, renamed: ({oldName, newName}: {oldName: string; newName: string}) => `ha rinominato la sede "${oldName}" in "${newName}"`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => - `ha cambiato l'indirizzo della sede "${name}" in "${newAddress}" (precedentemente "${oldAddress}")`, + `ha modificato l’indirizzo della sede "${name}" in "${newAddress}" (in precedenza "${oldAddress}")`, setAsPrimary: ({name}: {name: string}) => `imposta la sede "${name}" come principale`, setCompanyAddressAsPrimary: 'imposta l’indirizzo aziendale come principale', removed: ({name}: {name: string}) => `ha rimosso la sede "${name}"`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index ec02f89d9a95..9e3084f227ec 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7751,18 +7751,18 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から }, officeLocations: { title: 'オフィス所在地', - subtitle: 'オフィス所在地を追加して、距離の計測などのためにメンバーに割り当てましょう。', + subtitle: '距離の追跡などのためにメンバーに割り当てられるオフィス所在地を追加します。', addOfficeLocation: 'オフィス所在地を追加', editOfficeLocation: 'オフィス所在地を編集', locationName: '場所名', - locationNameHint: '複数のオフィス所在地を追加する場合は、場所のニックネームを選択してください', + locationNameHint: '複数のオフィス所在地を追加する場合は、所在地のニックネームを選択してください', defaultName: ({officeNumber}: {officeNumber: number}) => `オフィス所在地 ${officeNumber}`, - setAsPrimary: '主要に設定', + setAsPrimary: '優先に設定', primary: 'メイン', deleteOfficeLocation: 'オフィス所在地を削除しますか?', - deleteOfficeLocationConfirmation: 'このオフィス所在地を本当に削除しますか?メンバーはメインのオフィスに再割り当てされます。', + deleteOfficeLocationConfirmation: 'このオフィス所在地を本当に削除しますか?メンバーはプライマリのオフィスに再割り当てされます。', existingOfficeLocationError: 'この名前のオフィス所在地はすでに存在します', - setCompanyAddressAsPrimaryConfirmation: '会社の住所を主なオフィスとして設定しますか?', + setCompanyAddressAsPrimaryConfirmation: '会社住所を主なオフィス所在地として設定しますか?', }, bankAccount: { continueWithSetup: 'セットアップを続行', @@ -9547,9 +9547,9 @@ ${reportName}`, added: ({name, address}: {name: string; address: string}) => `オフィス所在地「${name}」(${address})を追加しました`, renamed: ({oldName, newName}: {oldName: string; newName: string}) => `オフィス所在地名を「${oldName}」から「${newName}」に変更しました`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => - `オフィス拠点「${name}」の住所を「${newAddress}」に変更しました(以前の住所:「${oldAddress}」)`, - setAsPrimary: ({name}: {name: string}) => `オフィス所在地「${name}」を主な拠点に設定します`, - setCompanyAddressAsPrimary: '会社住所を主住所として設定する', + `オフィス所在地「${name}」の住所を「${newAddress}」(以前は「${oldAddress}」)に変更しました`, + setAsPrimary: ({name}: {name: string}) => `オフィス所在地「${name}」を主勤務地に設定します`, + setCompanyAddressAsPrimary: '会社の住所を主要に設定', removed: ({name}: {name: string}) => `オフィス所在地「${name}」を削除しました`, }, workArrangement: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 1c72f0bfa439..e14b282258d4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7826,17 +7826,17 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, policy: 'Declaratiebeleid', }, officeLocations: { - title: 'Kantoorlocaties', + title: 'Kantoorlokaties', subtitle: 'Voeg kantoorlocaties toe om aan leden toe te wijzen voor zaken als afstandsregistratie.', - addOfficeLocation: 'Kantoorlocatie toevoegen', - editOfficeLocation: 'Kantoorlokatie bewerken', + addOfficeLocation: 'Kantoorlokatie toevoegen', + editOfficeLocation: 'Kantoorlocatie bewerken', locationName: 'Locatienaam', - locationNameHint: 'Kies een locatiebijnaam als je meerdere kantoorlocaties toevoegt', + locationNameHint: 'Kies een bijnaam voor de locatie als je meerdere kantoorlocaties toevoegt', defaultName: ({officeNumber}: {officeNumber: number}) => `Kantoorlocatie ${officeNumber}`, - setAsPrimary: 'Instellen als primair', + setAsPrimary: 'Als primair instellen', primary: 'Primair', - deleteOfficeLocation: 'Locatie kantoor verwijderen?', - deleteOfficeLocationConfirmation: 'Wil je deze kantoorlocatie echt verwijderen? Leden worden opnieuw toegewezen aan het primaire kantoor.', + deleteOfficeLocation: 'Kantoorlocatie verwijderen?', + deleteOfficeLocationConfirmation: 'Wil je deze kantoorlocatie echt verwijderen? Leden worden opnieuw toegewezen aan het hoofdkantoor.', existingOfficeLocationError: 'Er bestaat al een kantoorlocatie met deze naam', setCompanyAddressAsPrimaryConfirmation: 'Het bedrijfsadres instellen als hoofdvestiging?', }, @@ -9652,10 +9652,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `heeft „${prohibitedExpense}” verwijderd uit verboden uitgaven`, officeLocation: { added: ({name, address}: {name: string; address: string}) => `heeft de kantoorlocatie "${name}" (${address}) toegevoegd`, - renamed: ({oldName, newName}: {oldName: string; newName: string}) => `heeft de kantoorlocatie “${oldName}” hernoemd naar “${newName}”`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `heeft de kantoorlokatie “${oldName}” gewijzigd in “${newName}”`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => - `heeft het adres van de kantoorlocatie ‘${name}’ gewijzigd in ‘${newAddress}’ (voorheen ‘${oldAddress}’)`, - setAsPrimary: ({name}: {name: string}) => `stel de kantoorlocatie ‘${name}’ in als primair`, + `heeft het adres van kantoorlocatie "${name}" gewijzigd naar "${newAddress}" (voorheen "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `stel de kantoorlocatie "${name}" in als primair`, setCompanyAddressAsPrimary: 'stel het bedrijfsadres in als primair', removed: ({name}: {name: string}) => `heeft de kantoorlocatie "${name}" verwijderd`, }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index c0f28240511b..9fc199440256 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7851,14 +7851,14 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, }, officeLocations: { title: 'Lokalizacje biur', - subtitle: 'Dodaj lokalizacje biur, żeby przypisywać je członkom, na przykład do śledzenia odległości.', + subtitle: 'Dodaj lokalizacje biur, aby przypisywać je członkom do rzeczy takich jak śledzenie dystansu.', addOfficeLocation: 'Dodaj lokalizację biura', editOfficeLocation: 'Edytuj lokalizację biura', locationName: 'Nazwa lokalizacji', locationNameHint: 'Wybierz nazwę lokalizacji, jeśli dodajesz wiele biur', defaultName: ({officeNumber}: {officeNumber: number}) => `Lokalizacja biura ${officeNumber}`, setAsPrimary: 'Ustaw jako główny', - primary: 'Podstawowe', + primary: 'Główny', deleteOfficeLocation: 'Usunąć lokalizację biura?', deleteOfficeLocationConfirmation: 'Czy na pewno chcesz usunąć tę lokalizację biura? Członkowie zostaną przypisani do głównego biura.', existingOfficeLocationError: 'Lokalizacja biura o tej nazwie już istnieje', @@ -9663,13 +9663,13 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `dodano „${prohibitedExpense}” do zabronionych wydatków`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `usunięto „${prohibitedExpense}” z wydatków zabronionych`, officeLocation: { - added: ({name, address}: {name: string; address: string}) => `dodał(-a) lokalizację biura „${name}” (${address})`, + added: ({name, address}: {name: string; address: string}) => `dodał(a) lokalizację biura „${name}” (${address})`, renamed: ({oldName, newName}: {oldName: string; newName: string}) => `zmienił(a) nazwę lokalizacji biura z „${oldName}” na „${newName}”`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => - `zmienił adres biura „${name}” na „${newAddress}” (poprzednio „${oldAddress}”)`, + `zmienił(a) adres lokalizacji biura „${name}” na „${newAddress}” (wcześniej „${oldAddress}”)`, setAsPrimary: ({name}: {name: string}) => `ustaw lokalizację biura „${name}” jako główną`, - setCompanyAddressAsPrimary: 'ustaw adres firmy jako główny', - removed: ({name}: {name: string}) => `usunął(-ę) lokalizację biura „${name}”`, + setCompanyAddressAsPrimary: 'ustaw firmowy adres jako główny', + removed: ({name}: {name: string}) => `usunięto lokalizację biura „${name}”`, }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `ustaw domyślny tryb pracy na ${arrangement}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 30b228bcb14d..82eb48cad720 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7832,16 +7832,16 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, }, officeLocations: { title: 'Locais do escritório', - subtitle: 'Adicione locais de escritório para atribuir a membros em coisas como rastreamento de distância.', + subtitle: 'Adicione locais de escritório para atribuir aos membros, por exemplo, para rastreamento de distância.', addOfficeLocation: 'Adicionar local do escritório', - editOfficeLocation: 'Editar local do escritório', + editOfficeLocation: 'Editar localização do escritório', locationName: 'Nome do local', locationNameHint: 'Escolha um apelido para o local se você estiver adicionando vários escritórios', defaultName: ({officeNumber}: {officeNumber: number}) => `Local do escritório ${officeNumber}`, setAsPrimary: 'Definir como principal', primary: 'Principal', deleteOfficeLocation: 'Excluir local do escritório?', - deleteOfficeLocationConfirmation: 'Deseja realmente excluir este escritório? Os membros serão reatribuídos ao escritório principal.', + deleteOfficeLocationConfirmation: 'Você realmente quer excluir este escritório? Os membros serão realocados para o escritório principal.', existingOfficeLocationError: 'Já existe um local de escritório com esse nome', setCompanyAddressAsPrimaryConfirmation: 'Definir o endereço da empresa como escritório principal?', }, @@ -9649,11 +9649,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `adicionou "${prohibitedExpense}" às despesas proibidas`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `removeu "${prohibitedExpense}" das despesas proibidas`, officeLocation: { - added: ({name, address}: {name: string; address: string}) => `adicionou o local de escritório "${name}" (${address})`, + added: ({name, address}: {name: string; address: string}) => `adicionou a localização do escritório "${name}" (${address})`, renamed: ({oldName, newName}: {oldName: string; newName: string}) => `renomeou o local do escritório de "${oldName}" para "${newName}"`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => - `alterou o endereço da filial "${name}" para "${newAddress}" (anteriormente "${oldAddress}")`, - setAsPrimary: ({name}: {name: string}) => `definir o local do escritório "${name}" como principal`, + `alterou o endereço da localização do escritório "${name}" para "${newAddress}" (anteriormente "${oldAddress}")`, + setAsPrimary: ({name}: {name: string}) => `definir o local de escritório "${name}" como principal`, setCompanyAddressAsPrimary: 'definir o endereço da empresa como principal', removed: ({name}: {name: string}) => `removeu o local do escritório "${name}"`, }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 898b7db4f863..523b9880a753 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7559,18 +7559,18 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM }, officeLocations: { title: '办公地点', - subtitle: '添加办公地点,并将其分配给成员,用于距离追踪等用途。', + subtitle: '添加办公地点,以便为成员分配用于里程跟踪等用途。', addOfficeLocation: '添加办公地点', - editOfficeLocation: '编辑办公室地点', + editOfficeLocation: '编辑办公室位置', locationName: '位置名称', - locationNameHint: '如果你要添加多个办公地点,请为此地点选择一个昵称', + locationNameHint: '如果你要添加多个办公地点,请为每个地点选择一个昵称', defaultName: ({officeNumber}: {officeNumber: number}) => `办公室位置 ${officeNumber}`, setAsPrimary: '设为主要', primary: '主要', deleteOfficeLocation: '删除办公地点?', - deleteOfficeLocationConfirmation: '确定要删除此办公室地点吗?成员将被重新分配到主要办公地点。', + deleteOfficeLocationConfirmation: '你确定要删除此办公地点吗?成员将被重新分配到主办公地点。', existingOfficeLocationError: '已存在同名的办公地点', - setCompanyAddressAsPrimaryConfirmation: '将公司地址设为主要办公地点?', + setCompanyAddressAsPrimaryConfirmation: '将公司地址设为主要办公室?', }, bankAccount: { continueWithSetup: '继续设置', @@ -9292,13 +9292,13 @@ ${reportName}`, addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `已将“${prohibitedExpense}”添加到禁止报销的费用中`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `已从禁用报销类别中移除“${prohibitedExpense}”`, officeLocation: { - added: ({name, address}: {name: string; address: string}) => `已添加办公地点“${name}” (${address})`, - renamed: ({oldName, newName}: {oldName: string; newName: string}) => `已将办公地点“${oldName}”重命名为“${newName}”`, + added: ({name, address}: {name: string; address: string}) => `已添加办公地点“${name}”(${address})`, + renamed: ({oldName, newName}: {oldName: string; newName: string}) => `已将办公地点名称从“${oldName}”重命名为“${newName}”`, changedAddress: ({name, newAddress, oldAddress}: {name: string; newAddress: string; oldAddress: string}) => - `将办公地点“${name}”的地址更改为“${newAddress}”(原为“${oldAddress}”)`, + `已将办公地点“${name}”的地址更改为“${newAddress}”(原为“${oldAddress}”)`, setAsPrimary: ({name}: {name: string}) => `将办公地点“${name}”设为主要地点`, setCompanyAddressAsPrimary: '将公司地址设为主要地址', - removed: ({name}: {name: string}) => `已移除办公地点“${name}”`, + removed: ({name}: {name: string}) => `已移除办公室地点“${name}”`, }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `将默认工作安排设置为 ${arrangement}`, From 9e22880aaea0a32b014e6c90e78a6d30b02114da Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Wed, 14 Oct 2026 10:52:59 +0000 Subject: [PATCH 650/781] Fix: Apply oxfmt formatting to DynamicIOURequestStepDestination Co-authored-by: Eskalifer1 --- .../iou/request/step/DynamicIOURequestStepDestination.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx index e62fbb6c6b76..eaa76a092e2e 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx @@ -254,9 +254,7 @@ function DynamicIOURequestStepDestination({ icon={illustrations.EmptyStateExpenses} title={translate('workspace.perDiem.requestEmptyList.title')} subtitle={ - isPolicyAdmin(policy) - ? translate('workspace.perDiem.requestEmptyList.adminSubtitle') - : translate('workspace.perDiem.requestEmptyList.subtitle', policy?.name) + isPolicyAdmin(policy) ? translate('workspace.perDiem.requestEmptyList.adminSubtitle') : translate('workspace.perDiem.requestEmptyList.subtitle', policy?.name) } containerStyle={[styles.flex1, styles.justifyContentCenter]} /> From ba2349c900604c1206845c528d9b634c6c790567 Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Wed, 14 Oct 2026 11:03:02 +0000 Subject: [PATCH 651/781] =?UTF-8?q?Use=20=E6=AF=8F=E6=97=A5=E6=B4=A5?= =?UTF-8?q?=E8=B4=B4=20consistently=20in=20zh-hans=20per=20diem=20empty=20?= =?UTF-8?q?state=20subtitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Eskalifer1 --- src/languages/zh-hans.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ebf5d993329c..da1d2cc5600d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4919,7 +4919,8 @@ ${amount},商户:${merchant} - 日期:${date}`, }, requestEmptyList: { title: '尚未设置每日津贴费率', - subtitle: (workspaceName?: string) => (workspaceName ? `${workspaceName} 没有任何日津贴标准。请联系管理员添加一些。` : '此工作区没有任何日津贴标准。请联系管理员添加一些。'), + subtitle: (workspaceName?: string) => + workspaceName ? `${workspaceName} 没有任何每日津贴标准。请联系管理员添加一些。` : '此工作区没有任何每日津贴标准。请联系管理员添加一些。', adminSubtitle: '添加每日津贴标准,以整理您的支出。', }, importPerDiemRates: '导入每日津贴费率', From a03e0d24882d96eb0ae764cf04477951be15c29e Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Wed, 14 Oct 2026 11:07:27 +0000 Subject: [PATCH 652/781] Remove expenses that leave a report from that report's carousel Co-authored-by: Eskalifer1 --- .../actions/TransactionThreadNavigation.ts | 33 ++++++++++++ .../hooks/useReportSelectionActions.ts | 3 ++ .../TransactionThreadNavigationTest.ts | 54 +++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 026ec10e4d70..82fbf8c0756f 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -201,6 +201,38 @@ function clearActiveTransactionIDsForSource(source: string) { return clearActiveTransactionIDs(); } +/** + * Takes expenses that just left `reportID` out of a carousel seeded from that report. + * + * A report-scoped carousel lists the report's own expenses, so an expense moved or removed from the report no longer + * belongs in it. Leaving it there kept the arrows on the report's header after it dropped to one expense, and + * stepping to the moved expense opened it in its new home (for example the self-DM). Carousels from other screens, + * such as search results, legitimately span several reports and are left alone. + */ +function removeActiveTransactionIDsForReport(transactionIDs: string[], reportID: string | undefined) { + if (!lastSetIDs?.length || !transactionIDs.length || !reportID) { + return Promise.resolve(); + } + + const reportSources = new Set([CAROUSEL_SOURCE.report(reportID), CAROUSEL_SOURCE.reportRow(reportID), CAROUSEL_SOURCE.reportPreview(reportID)]); + if (!lastSetSource || !reportSources.has(lastSetSource)) { + return Promise.resolve(); + } + + const removedIDs = new Set(transactionIDs); + const remainingIDs = lastSetIDs.filter((transactionID) => !removedIDs.has(transactionID)); + if (remainingIDs.length === lastSetIDs.length) { + return Promise.resolve(); + } + + // One expense left is nothing to page through. + if (remainingIDs.length < 2) { + return clearActiveTransactionIDs(); + } + + return setActiveTransactionIDs(remainingIDs, {source: lastSetSource, snapshotHash: lastSetSnapshotHash ?? undefined, descriptors: lastSetDescriptors ?? undefined}); +} + /** * Gives up ownership of the carousel without clearing it, for a writer that unmounts while the list it seeded is * still driving the arrows of an expense open on top of it. @@ -221,6 +253,7 @@ export { setActiveTransactionIDs, clearActiveTransactionIDs, clearActiveTransactionIDsForSource, + removeActiveTransactionIDsForReport, disownActiveTransactionIDs, getActiveTransactionIDs, shouldRefreshActiveTransactionIDs, diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index cd18b4c2b28e..ca2835c431c9 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -10,6 +10,7 @@ import usePermissions from '@hooks/usePermissions'; import {setCustomUnitID, setCustomUnitRateID} from '@libs/actions/IOU/MoneyRequest'; import {clearSubrates} from '@libs/actions/IOU/PerDiem'; import {changeTransactionsReport, setTransactionReport} from '@libs/actions/Transaction'; +import {removeActiveTransactionIDsForReport} from '@libs/actions/TransactionThreadNavigation'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -227,6 +228,7 @@ function useReportSelectionActions({ getCurrencyDecimals, getCurrencySymbol, }); + removeActiveTransactionIDsForReport(targetTransactionIDs, transaction.reportID); removeTransaction(transaction.transactionID); } }, @@ -260,6 +262,7 @@ function useReportSelectionActions({ getCurrencyDecimals, getCurrencySymbol, }); + removeActiveTransactionIDsForReport(targetTransactionIDs, transaction.reportID); removeTransaction(transaction.transactionID); }, }); diff --git a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts index b0fabc4c81f3..3e1f8fc3b452 100644 --- a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts +++ b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts @@ -4,6 +4,7 @@ import { clearActiveTransactionIDsForSource, disownActiveTransactionIDs, getActiveTransactionIDs, + removeActiveTransactionIDsForReport, setActiveTransactionIDs, shouldRefreshActiveTransactionIDs, } from '@libs/actions/TransactionThreadNavigation'; @@ -279,4 +280,57 @@ describe('TransactionThreadNavigation carousel ownership', () => { expect(getActiveTransactionIDs()).toEqual({ids: SPEND_PAGE_IDS, descriptors: null, source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); }); }); + + /** + * Regression guard for https://github.com/Expensify/App/issues/103944: removing an expense from a two-expense + * report left it in the report's carousel, so the report kept its arrows and stepping to the removed expense + * opened the self-DM. + */ + describe('removeActiveTransactionIDsForReport', () => { + it('clears a report row carousel that drops to one expense', async () => { + // Given an expense opened from report B's list, seeding the carousel with both of B's expenses + await setActiveTransactionIDs(REPORT_B_IDS, {source: CAROUSEL_SOURCE.reportRow('B')}); + + // When one of them is removed from report B + await removeActiveTransactionIDsForReport(['B1'], 'B'); + + // Then there is nothing left to page through, so the carousel is cleared and no arrows show + expect(getActiveTransactionIDs()).toEqual({ids: null, descriptors: null, source: null, snapshotHash: null}); + expect((await readCarouselFromOnyx()).ids).toBeUndefined(); + }); + + it('keeps paging through the expenses still on the report', async () => { + // Given report B's own carousel with three expenses + await setActiveTransactionIDs(['B1', 'B2', 'B3'], {source: REPORT_SOURCE}); + + // When one expense moves to another report + await removeActiveTransactionIDsForReport(['B2'], 'B'); + + // Then the other two stay in the carousel under the same owner + expect(getActiveTransactionIDs()).toEqual({ids: ['B1', 'B3'], descriptors: null, source: REPORT_SOURCE, snapshotHash: null}); + expect((await readCarouselFromOnyx()).ids).toEqual(['B1', 'B3']); + }); + + it('leaves a search carousel alone, since it spans several reports', async () => { + // Given a carousel seeded from the Spend page, which holds expenses from reports A and B + await setActiveTransactionIDs(SPEND_PAGE_IDS, {source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); + + // When an expense is removed from report B + await removeActiveTransactionIDsForReport(['B1'], 'B'); + + // Then the search carousel is untouched, because the expense is still one of its results + expect(getActiveTransactionIDs()).toEqual({ids: SPEND_PAGE_IDS, descriptors: null, source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); + }); + + it("leaves another report's carousel alone", async () => { + // Given report B's carousel + await setActiveTransactionIDs(REPORT_B_IDS, {source: REPORT_SOURCE}); + + // When an expense leaves a different report + await removeActiveTransactionIDsForReport(['B1'], 'A'); + + // Then report B's carousel is untouched + expect(getActiveTransactionIDs()).toEqual({ids: REPORT_B_IDS, descriptors: null, source: REPORT_SOURCE, snapshotHash: null}); + }); + }); }); From 0478ce75c6344b6fcd70b029c3e9b345b7221612 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 14 Oct 2026 13:08:30 +0200 Subject: [PATCH 653/781] fix: keep modal list edits out of drafts and show readonly list entries --- .../DynamicForm/adapters/ListFieldAdapter.tsx | 14 +++++-- .../components/DynamicFormFieldRow.tsx | 11 +++++- .../components/DynamicFormFields.tsx | 3 +- .../components/DynamicFormListItemModal.tsx | 22 +++++------ src/components/DynamicForm/index.tsx | 4 ++ .../DynamicForm/renderers/listRenderer.tsx | 1 + src/components/DynamicForm/renderers/types.ts | 2 +- .../utils/getSensitiveDefaultValue.ts | 3 +- .../DynamicForm/DynamicFormFieldsTest.tsx | 27 ++++++++++++++ .../adapters/ListFieldAdapterTest.tsx | 37 ++++++++++++++++--- 10 files changed, 100 insertions(+), 24 deletions(-) diff --git a/src/components/DynamicForm/adapters/ListFieldAdapter.tsx b/src/components/DynamicForm/adapters/ListFieldAdapter.tsx index 5398c6f14e3e..07913d5c6537 100644 --- a/src/components/DynamicForm/adapters/ListFieldAdapter.tsx +++ b/src/components/DynamicForm/adapters/ListFieldAdapter.tsx @@ -46,6 +46,9 @@ type ListFieldAdapterProps = Pick, 'addTitle' | /** The fields of one entry, which name and describe its row */ itemFields: DynamicFormField[]; + /** The list is marked `sensitive`, so none of its entries' answers is drafted while the modal edits one */ + isSensitive?: boolean; + /** Hides the add row once the list holds this many entries */ maxItems?: number; @@ -56,7 +59,7 @@ type ListFieldAdapterProps = Pick, 'addTitle' | onEdit?: (itemID: string) => void; /** Draws the entry's fields inside the modal editor */ - renderFields: (fields: DynamicFormField[], values: DynamicFormValues) => ReactNode; + renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; }; /** The entries of a list field as rows. Removing one updates FormProvider's value; adding and editing open the flow's editor page, or a modal outside the flow. */ @@ -68,6 +71,7 @@ function ListFieldAdapter({ label, itemLabel, itemFields, + isSensitive = false, maxItems, addTitle, addDescription, @@ -87,10 +91,11 @@ function ListFieldAdapter({ modalTitle = editingTitle === '' ? label : editingTitle; } const sensitiveKeys = new Set(itemFields.filter(isSensitiveField).flatMap(getAnswerKeys)); + const isKeptOutOfDraft = (key: string) => key !== 'id' && (isSensitive || sensitiveKeys.has(key)); - // Sensitive answers are not drafted, so the modal shows them blank and a blank one keeps the stored answer + // Sensitive answers are not drafted, so the modal's inputs start from the stored entry instead. Every answer of a sensitive list is. const openModal = (item?: DynamicFormListItem) => { - const answers = Object.fromEntries(Object.entries(item ?? {}).filter(([key]) => key !== 'id' && !sensitiveKeys.has(key))); + const answers = Object.fromEntries(Object.entries(item ?? {}).filter(([key]) => key !== 'id' && !isKeptOutOfDraft(key))); startListItemEdit(answers).then(() => setEditingID(item?.id ?? Str.guid())); }; @@ -155,7 +160,8 @@ function ListFieldAdapter({ title={modalTitle} itemFields={itemFields} forwardedFSClass={forwardedFSClass} - keptAnswers={Object.fromEntries(Object.entries(editingItem ?? {}).filter(([key]) => sensitiveKeys.has(key)))} + keptAnswers={Object.fromEntries(Object.entries(editingItem ?? {}).filter(([key]) => isKeptOutOfDraft(key)))} + isSensitive={isSensitive} renderFields={renderFields} onSave={saveItem} onClose={closeModal} diff --git a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx index 2b0f1b0b0048..edf9a0f07f99 100644 --- a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx +++ b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx @@ -3,6 +3,8 @@ import type {DynamicFieldContext} from '@components/DynamicForm/renderers/types' import formatDynamicFieldValue from '@components/DynamicForm/utils/formatDynamicFieldValue'; import getLocalizedText, {getFieldLabel} from '@components/DynamicForm/utils/getLocalizedText'; import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; +import {getListItems} from '@components/DynamicForm/utils/listItems'; +import summarizeListItem from '@components/DynamicForm/utils/summarizeListItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; @@ -51,6 +53,13 @@ function DynamicFormFieldRow({ const label = getFieldLabel(field, translate); if (field.readonly) { + // A list's answer is its entries, which formatDynamicFieldValue cannot name, so they are listed by their titles + const readonlyValue = + field.type === 'list' + ? getListItems(values[field.key]) + .map((item) => summarizeListItem(item, field.itemFields, translate).title) + .join(', ') + : formatDynamicFieldValue(field, values, translate); return ( ); diff --git a/src/components/DynamicForm/components/DynamicFormFields.tsx b/src/components/DynamicForm/components/DynamicFormFields.tsx index f056bc678a7d..ab4944ff55fb 100644 --- a/src/components/DynamicForm/components/DynamicFormFields.tsx +++ b/src/components/DynamicForm/components/DynamicFormFields.tsx @@ -59,10 +59,11 @@ function DynamicFormFields({ }: DynamicFormFieldsProps) { const visibleContent = getVisibleContent(content, values, allFields); const loneField = getLoneField(getContentFields(visibleContent)); - const renderFields = (itemFields: DynamicFormField[], itemValues: DynamicFormValues) => ( + const renderFields = (itemFields: DynamicFormField[], itemValues: DynamicFormValues, isItemSensitive: boolean) => ( diff --git a/src/components/DynamicForm/components/DynamicFormListItemModal.tsx b/src/components/DynamicForm/components/DynamicFormListItemModal.tsx index b13b450d76f4..acd3f61b13f4 100644 --- a/src/components/DynamicForm/components/DynamicFormListItemModal.tsx +++ b/src/components/DynamicForm/components/DynamicFormListItemModal.tsx @@ -31,11 +31,14 @@ type DynamicFormListItemModalProps = ForwardedFSClassProps & { /** The fields of one entry */ itemFields: DynamicFormField[]; - /** Answers the form cannot show, such as stored sensitive ones, used when the user leaves them blank */ + /** Stored answers that are not drafted, such as sensitive ones, which the inputs start from */ keptAnswers: DynamicFormValues; + /** Every answer of the entry is sensitive, as in a list marked `sensitive`: none is drafted */ + isSensitive: boolean; + /** Draws the entry's fields inside the modal's form */ - renderFields: (fields: DynamicFormField[], values: DynamicFormValues) => ReactNode; + renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; /** Called with the entry's answers when the user saves */ onSave: (answers: DynamicFormValues) => void; @@ -45,15 +48,12 @@ type DynamicFormListItemModalProps = ForwardedFSClassProps & { }; /** The editor of one list entry outside DynamicFormFlow, as a modal over the page holding the list */ -function DynamicFormListItemModal({isVisible, title, itemFields, keptAnswers, renderFields, onSave, onClose, forwardedFSClass}: DynamicFormListItemModalProps) { +function DynamicFormListItemModal({isVisible, title, itemFields, keptAnswers, isSensitive, renderFields, onSave, onClose, forwardedFSClass}: DynamicFormListItemModalProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [, itemDraftMetadata] = useOnyx(ONYXKEYS.FORMS.DYNAMIC_FORM_LIST_ITEM_FORM_DRAFT); - const withKeptAnswers = (inputValues: unknown): DynamicFormValues => { - const answers = toDynamicFormValues(inputValues); - const keptBlankAnswers = Object.entries(keptAnswers).filter(([key]) => (answers[key] ?? '') === ''); - return {...answers, ...Object.fromEntries(keptBlankAnswers)}; - }; + // Kept answers fill the inputs, so clearing one in the form clears it on save, as on the flow's editor page + const withInputValues = (inputValues: unknown): DynamicFormValues => ({...keptAnswers, ...toDynamicFormValues(inputValues)}); return ( getDynamicFieldErrors(itemFields, withKeptAnswers(inputValues), translate)} - onSubmit={(inputValues) => onSave(withKeptAnswers(inputValues))} + validate={(inputValues) => getDynamicFieldErrors(itemFields, withInputValues(inputValues), translate)} + onSubmit={(inputValues) => onSave(withInputValues(inputValues))} style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={styles.mb0} enabledWhenOffline > - {({inputValues}) => renderFields(itemFields, toDynamicFormValues(inputValues))} + {({inputValues}) => renderFields(itemFields, withInputValues(inputValues), isSensitive)} )} diff --git a/src/components/DynamicForm/index.tsx b/src/components/DynamicForm/index.tsx index 516ce17da001..7980ef22174b 100644 --- a/src/components/DynamicForm/index.tsx +++ b/src/components/DynamicForm/index.tsx @@ -215,6 +215,10 @@ function DynamicFormFlow({ const openListItemEditor = (listKey: string, itemID?: string) => { const editorIndex = subPages.findIndex((page) => page.pageName === getListItemPageName(listKey, itemID)); + // moveTo(-1) would open the last sub-page, which is another entry's editor + if (editorIndex === -1) { + return; + } moveTo(editorIndex, isEditing || currentPageName === CONST.DYNAMIC_FORM.CONFIRMATION_PAGE_KEY); }; diff --git a/src/components/DynamicForm/renderers/listRenderer.tsx b/src/components/DynamicForm/renderers/listRenderer.tsx index 2106f6e37942..1e99405f5776 100644 --- a/src/components/DynamicForm/renderers/listRenderer.tsx +++ b/src/components/DynamicForm/renderers/listRenderer.tsx @@ -29,6 +29,7 @@ const renderList: DynamicFieldRenderer<'list'> = (field, {translate, isLoneField label={getFieldLabel(field, translate)} itemLabel={itemLabel} itemFields={field.itemFields} + isSensitive={isSensitiveField(field)} maxItems={field.maxItems} addTitle={itemLabel ? translate('dynamicForm.addItem', {item: itemLabel}) : translate('common.add')} addDescription={getLocalizedText(translate, field.addItemDescriptionKey, field.addItemDescription)} diff --git a/src/components/DynamicForm/renderers/types.ts b/src/components/DynamicForm/renderers/types.ts index 298d393a4ed3..d13ec5e7dd04 100644 --- a/src/components/DynamicForm/renderers/types.ts +++ b/src/components/DynamicForm/renderers/types.ts @@ -20,7 +20,7 @@ type DynamicFieldContext = Pick ReactNode; + renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; }; /** The answer each field type's input holds. An address holds its street, and its other parts sit under their own keys. */ diff --git a/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts index 0f78ddf4e6a5..6e799e8bb578 100644 --- a/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts +++ b/src/components/DynamicForm/utils/getSensitiveDefaultValue.ts @@ -6,6 +6,7 @@ import type {DynamicFormFieldType, DynamicFormListItem} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import isSensitiveField from './isSensitiveField'; +import {isListItem} from './listItems'; type AnswerGuards = {[TType in DynamicFormFieldType]: (answer: FormValue) => answer is DynamicFieldAnswerMap[TType]}; @@ -26,7 +27,7 @@ function isFileList(answer: FormValue): answer is FileObject[] { } function isListItems(answer: FormValue): answer is DynamicFormListItem[] { - return Array.isArray(answer) && answer.every((item) => typeof item === 'object' && 'id' in item); + return Array.isArray(answer) && answer.every(isListItem); } const ANSWER_GUARDS: AnswerGuards = { diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx index 8ddaa3f0cc96..755eca38bfca 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx @@ -566,6 +566,33 @@ describe('DynamicFormFields', () => { expect(screen.getByText('25%')).toBeOnTheScreen(); }); + it('shows a readonly list as the names of its entries', () => { + // Given a readonly list of owners holding two entries + const owners: DynamicFormField = { + key: 'owners', + type: 'list', + required: true, + readonly: true, + itemFields: [ + {key: 'firstName', type: 'text', required: true}, + {key: 'lastName', type: 'text', required: true}, + ], + }; + const values = { + owners: [ + {id: 'jane', firstName: 'Jane', lastName: 'Doe'}, + {id: 'john', firstName: 'John', lastName: 'Roe'}, + ], + } as DynamicFormValues; + + // When it renders + const rendered = renderFields([owners], values); + + // Then no input is drawn, and the row names every owner, since a readonly list cannot be opened to see them + expect(rendered.size).toBe(0); + expect(screen.getByText('Jane Doe, John Roe')).toBeOnTheScreen(); + }); + it.each([ ['select', InlineSelectionListAdapter, {valueType: 'string'}], ['multiselect', InlineSelectionListAdapter, {canSelectMultiple: true, valueType: 'stringList'}], diff --git a/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx b/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx index 444752448001..16c1da5a211c 100644 --- a/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx +++ b/tests/unit/components/DynamicForm/adapters/ListFieldAdapterTest.tsx @@ -4,7 +4,7 @@ import ListFieldAdapter from '@components/DynamicForm/adapters/ListFieldAdapter' import type {ListFieldRow} from '@components/ListField'; import {ModalActions} from '@components/Modal/Global/ModalContext'; -import {clearListItemEdit} from '@userActions/DynamicForm'; +import {clearListItemEdit, startListItemEdit} from '@userActions/DynamicForm'; import type {DynamicFormField, DynamicFormListItem} from '@src/types/onyx'; @@ -20,6 +20,7 @@ type CapturedListFieldProps = { type CapturedModalProps = { isVisible: boolean; + isSensitive: boolean; keptAnswers: Record; onSave: (answers: Record) => void; onClose: () => void; @@ -111,21 +112,47 @@ describe('ListFieldAdapter', () => { expect(onInputChange).not.toHaveBeenCalled(); }); - it('edits an entry in a modal outside the flow, keeping a stored sensitive answer the user leaves blank', async () => { + it('edits an entry in a modal outside the flow, filling a stored sensitive answer from the entry instead of the draft', async () => { // Given a director with a stored SSN, in a list that no flow opens editor pages for const onInputChange = jest.fn(); const {onEdit} = renderAdapter([{...jane, ssn: '123456789'}], onInputChange, undefined, false); - // When the user edits the director, renames them and saves with the SSN left blank + // When the user edits the director, renames them and saves with the SSN they were shown await act(async () => onEdit('jane')); const modal = mockModal.mock.lastCall?.[0]; - act(() => modal?.onSave({fullName: 'Jane Smith', ...modal.keptAnswers})); + act(() => modal?.onSave({...modal.keptAnswers, fullName: 'Jane Smith'})); - // Then the modal opened over the page, and the list holds the renamed director with the SSN still in place + // Then the modal opened over the page with the SSN kept out of the editor draft, and the list holds the renamed director with the SSN still in place expect(modal?.isVisible).toBe(true); + expect(startListItemEdit).toHaveBeenLastCalledWith({fullName: 'Jane Doe'}); + expect(modal?.keptAnswers).toEqual({ssn: '123456789'}); expect(onInputChange).toHaveBeenCalledWith([{id: 'jane', fullName: 'Jane Smith', ssn: '123456789'}]); }); + it('drafts none of the answers of an entry of a sensitive list edited in the modal', async () => { + // Given a director in a list marked sensitive, which no flow opens editor pages for + render( + , + ); + + // When the user opens the director in the modal + await act(async () => mockListField.mock.lastCall?.[0].onEdit('jane')); + const modal = mockModal.mock.lastCall?.[0]; + + // Then the editor draft starts empty and the director's answers fill the inputs from the entry, so nothing of a sensitive list reaches the device + expect(startListItemEdit).toHaveBeenLastCalledWith({}); + expect(modal?.keptAnswers).toEqual({fullName: 'Jane Doe'}); + expect(modal?.isSensitive).toBe(true); + }); + it('empties the editor draft when the modal closes', async () => { // Given a director open in the modal editor outside the flow const {onEdit} = renderAdapter([jane], jest.fn(), undefined, false); From 7df0599324977079ee38b4cf43f5722db2fbca7f Mon Sep 17 00:00:00 2001 From: Olgierd Date: Wed, 14 Oct 2026 13:19:33 +0200 Subject: [PATCH 654/781] Introduce useTouchPinnedTooltip hook --- .../Charts/PieChart/PieChartContent.tsx | 24 +++------- src/components/Charts/hooks/index.ts | 1 + .../Charts/hooks/useChartInteractions.ts | 33 +++++--------- .../Charts/hooks/useTouchPinnedTooltip.ts | 45 +++++++++++++++++++ 4 files changed, 62 insertions(+), 41 deletions(-) create mode 100644 src/components/Charts/hooks/useTouchPinnedTooltip.ts diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index 16cb9ec1544d..aa3ef07cd178 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -1,9 +1,8 @@ import ActivityIndicator from '@components/ActivityIndicator'; import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; -import {TOOLTIP_BAR_GAP, useChartLabelFormats} from '@components/Charts/hooks'; +import {TOOLTIP_BAR_GAP, useChartLabelFormats, useTouchPinnedTooltip} from '@components/Charts/hooks'; import type {ChartDataPoint, ChartProps, PieSlice, UnitPosition} from '@components/Charts/types'; import {findSliceAtPosition, getPrimaryValue, processDataIntoSlices} from '@components/Charts/utils'; -import {markChartTouch} from '@components/Charts/utils/pinnedChartTooltip'; import VictoryTheme from '@components/Charts/VictoryTheme'; import Text from '@components/Text'; @@ -41,9 +40,6 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, const {translate} = useLocalize(); const [canvasWidth, setCanvasWidth] = useState(0); const [canvasHeight, setCanvasHeight] = useState(0); - - /** True while a slice tooltip opened by a touch tap is shown, which lets a touch anywhere on the screen hide it */ - const [isTouchPinned, setIsTouchPinned] = useState(false); const [isHoveringOverPie, setIsHoveringOverPie] = useState(false); // Shared values for hover state @@ -124,11 +120,10 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, const onChartMoved = (deltaX: number, deltaY: number) => scheduleOnUI(handleChartMoved, deltaX, deltaY); - const dismissTouchedSlice = () => { + const {setIsTouchPinned, onTooltipDismiss, onTapFinalize, onChartTouchStart} = useTouchPinnedTooltip(() => { matchedIndex.set(-1); - setIsTouchPinned(false); setIsHoveringOverPie(false); - }; + }); // Hover gesture const hoverGesture = () => @@ -165,14 +160,7 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, // Tap gesture for click/tap navigation const tapGesture = () => Gesture.Tap() - .onFinalize((e, success) => { - 'worklet'; - - if (success || e.pointerType !== PointerType.TOUCH || !isTouchPinned) { - return; - } - scheduleOnRN(dismissTouchedSlice); - }) + .onFinalize(onTapFinalize) .onEnd((e) => { 'worklet'; @@ -234,7 +222,7 @@ function PieChartContent({data, series, isLoading, valueUnit, valueUnitPosition, markChartTouch(isTouchPinned ? dismissTouchedSlice : undefined)} + onTouchStart={onChartTouchStart} > {processedSlices.length > 0 && ( diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 5fa23151604b..63f9204d5dde 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -9,3 +9,4 @@ export {default as useChartLabelFormats} from './useChartLabelFormats'; export {default as useDynamicYDomain} from './useDynamicYDomain'; export {default as useTooltipData} from './useTooltipData'; export {default as useLabelHitTesting} from './useLabelHitTesting'; +export {default as useTouchPinnedTooltip} from './useTouchPinnedTooltip'; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 6244fabd9bd0..ff0452868fad 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -1,13 +1,12 @@ -import {markChartTouch} from '@components/Charts/utils/pinnedChartTooltip'; - import type {SharedValue} from 'react-native-reanimated'; -import {useCallback, useState} from 'react'; +import {useCallback} from 'react'; import {Gesture, PointerType} from 'react-native-gesture-handler'; import {useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; import useChartInteractionState from './useChartInteractionState'; +import useTouchPinnedTooltip from './useTouchPinnedTooltip'; /** Gap between bar top and tooltip bottom */ const TOOLTIP_BAR_GAP = 8; @@ -196,9 +195,6 @@ function useChartInteractions({ const isCursorOverClickable = useSharedValue(false); const isTooltipActive = useSharedValue(false); - /** True while a tooltip opened by a touch tap is shown, which lets a touch anywhere on the screen hide it */ - const [isTouchPinned, setIsTouchPinned] = useState(false); - /** * Called by chart content from handleScaleChange to populate canvas positions. * Must be called with the positions derived from the current d3 scale. @@ -362,6 +358,11 @@ function useChartInteractions({ isTooltipActive.set(false); }; + const {setIsTouchPinned, dismissTouchTooltip, onTooltipDismiss, onTapFinalize, onChartTouchStart} = useTouchPinnedTooltip(() => { + hideTooltip(); + applyTargetIndex(-1); + }); + /** * Hover gesture to be placed on the full-height outer container (chart + label area). * Clamps the y coordinate to chartBottom before passing to Victory so that hovering @@ -403,12 +404,6 @@ function useChartInteractions({ hideTooltip(); }); - const dismissTouchTooltip = () => { - hideTooltip(); - applyTargetIndex(-1); - setIsTouchPinned(false); - }; - const handleTouchTap = (cursorX: number, cursorY: number) => { 'worklet'; @@ -427,15 +422,7 @@ function useChartInteractions({ */ const tapGesture = (onPress?: (index: number) => void) => Gesture.Tap() - // A touch on this chart that turns into a scroll leaves the pinned tooltip to the chart, so the chart hides it - .onFinalize((e, success) => { - 'worklet'; - - if (success || e.pointerType !== PointerType.TOUCH || !isTouchPinned) { - return; - } - scheduleOnRN(dismissTouchTooltip); - }) + .onFinalize(onTapFinalize) .onEnd((e) => { 'worklet'; @@ -516,9 +503,9 @@ function useChartInteractions({ /** Call with how far the chart moved in the window, e.g. on page scroll, to re-check the hover under a still cursor */ onChartMoved, /** Hides the tooltip pinned by a touch tap, undefined while nothing is pinned */ - onTooltipDismiss: isTouchPinned ? dismissTouchTooltip : undefined, + onTooltipDismiss, /** Marks a touch on this chart, so the screen's touch start keeps the tooltip and lets the tap switch it */ - onChartTouchStart: () => markChartTouch(isTouchPinned ? dismissTouchTooltip : undefined), + onChartTouchStart, }; } diff --git a/src/components/Charts/hooks/useTouchPinnedTooltip.ts b/src/components/Charts/hooks/useTouchPinnedTooltip.ts new file mode 100644 index 000000000000..fa5efe1fe9a8 --- /dev/null +++ b/src/components/Charts/hooks/useTouchPinnedTooltip.ts @@ -0,0 +1,45 @@ +import {markChartTouch} from '@components/Charts/utils/pinnedChartTooltip'; + +import {useState} from 'react'; +import {PointerType} from 'react-native-gesture-handler'; +import {scheduleOnRN} from 'react-native-worklets'; + +/** + * Tracks the tooltip pinned by a touch tap, which a touch anywhere on the screen hides. + * @param hideTooltip - hides the chart's tooltip and clears its matched target + */ +function useTouchPinnedTooltip(hideTooltip: () => void) { + /** True while a tooltip opened by a touch tap is shown */ + const [isTouchPinned, setIsTouchPinned] = useState(false); + + const dismissTouchTooltip = () => { + hideTooltip(); + setIsTouchPinned(false); + }; + + const onTooltipDismiss = isTouchPinned ? dismissTouchTooltip : undefined; + + const onTapFinalize = (e: {pointerType: PointerType}, success: boolean) => { + 'worklet'; + + if (success || e.pointerType !== PointerType.TOUCH || !isTouchPinned) { + return; + } + scheduleOnRN(dismissTouchTooltip); + }; + + return { + /** Sets whether the tooltip is pinned by a touch tap */ + setIsTouchPinned, + /** Hides the tooltip and unpins it */ + dismissTouchTooltip, + /** Hides the pinned tooltip, undefined while nothing is pinned */ + onTooltipDismiss, + /** Pass to the tap gesture's onFinalize */ + onTapFinalize, + /** Marks a touch on this chart, so the screen's touch start keeps the tooltip and lets the tap switch it */ + onChartTouchStart: () => markChartTouch(onTooltipDismiss), + }; +} + +export default useTouchPinnedTooltip; From 6672e791e514413531a4835ed78b737688790c53 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 14 Oct 2026 13:31:26 +0200 Subject: [PATCH 655/781] fix: make dynamic form list rows and editor title accessible --- .../components/DynamicFormListItemPage.tsx | 7 ++++++- src/components/ListField.tsx | 11 ++++++----- src/components/MenuItem/layout/MenuItemRoot.tsx | 10 ++++++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx index 0fa4b279813d..3c5d4563f2df 100644 --- a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx +++ b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx @@ -77,7 +77,12 @@ function DynamicFormListItemPage({currentPageName, allFields, values, currency, > {({inputValues}) => ( <> - {title} + + {title} + {rows.map((row) => ( - + // The row itself does nothing, so it stays out of the accessibility tree and screen readers can reach its buttons + ))} {canAddMore && ( - + diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index ce2105008840..1ae87cab583b 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -49,9 +49,15 @@ type MenuItemRootProps = PropsWithChildren & /** Accessibility state for the row, e.g. `{expanded}`. Lands on the pressable, which is what a screen reader focuses. */ accessibilityState?: AccessibilityState; + + /** + * Whether the row is one element for screen readers. Turn it off for a row without `onPress` whose + * buttons must stay reachable, since an accessible parent hides its children on iOS. + */ + shouldBeAccessible?: boolean; }; -function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style, accessibilityState}: MenuItemRootProps) { +function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, style, accessibilityState, shouldBeAccessible = true}: MenuItemRootProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const pressableRef = useRef>(null); @@ -114,7 +120,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI accessibilityLabel={rowAccessibilityLabel} accessibilityHint={accessibilityHint} accessibilityState={accessibilityState} - accessible + accessible={shouldBeAccessible} tabIndex={isInteractive ? 0 : -1} sentryLabel={sentryLabel} testID={testID} From 775edfaea73069864de1ac8c37185e643357a309 Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Wed, 14 Oct 2026 12:14:56 +0000 Subject: [PATCH 656/781] Move carousel cleanup into changeTransactionsReport so every move path drops moved expenses Co-authored-by: Eskalifer1 --- src/libs/actions/Transaction.ts | 28 ++++++- .../hooks/useReportSelectionActions.ts | 3 - tests/actions/TransactionTest.ts | 78 +++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 524e75902e0e..ce7c21bc10ee 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -90,6 +90,7 @@ import lodashClone from 'lodash/clone'; import Onyx from 'react-native-onyx'; import {getSearchOnyxUpdate} from './IOU/SearchUpdate'; +import {removeActiveTransactionIDsForReport} from './TransactionThreadNavigation'; type SaveWaypointProps = { transactionID: string; @@ -1048,6 +1049,8 @@ function getChangeTransactionsReportOnyxData({ // The transactions that are actually moved to the destination report. Transactions already in the destination are // skipped below, so this excludes them from the transactionList sent to the backend (which does not filter them out). const movedTransactionIDs: string[] = []; + // The moved transactions grouped by the report they leave, so a carousel seeded from that report can drop them. + const movedTransactionIDsByOldReportID: Record = {}; let shouldFixViolations = false; const policyHasDependentTags = hasDependentTags(policy, policyTagList); @@ -1137,6 +1140,7 @@ function getChangeTransactionsReportOnyxData({ movedTransactionIDs.push(transaction.transactionID); const oldReportID = isUnreportedExpense ? CONST.REPORT.UNREPORTED_REPORT_ID : transaction.reportID; + (movedTransactionIDsByOldReportID[oldReportID] ??= []).push(transaction.transactionID); const oldReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${oldReportID}`]; const sourceCurrency = oldReport?.currency; const shouldClearAmount = shouldClearConvertedAmount(transaction, sourceCurrency, destinationCurrency); @@ -2133,9 +2137,20 @@ function getChangeTransactionsReportOnyxData({ updatedReportReimbursableTotals, updatedReportUnheldReimbursableTotals, movedTransactionIDs, + movedTransactionIDsByOldReportID, }; } +/** + * Drops moved expenses from a carousel seeded from the report they left, so its arrows stop paging to them. + * Every move goes through `changeTransactionsReport`, so doing it here covers all entry points at once. + */ +function removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID: Record | undefined) { + for (const [oldReportID, transactionIDs] of Object.entries(movedTransactionIDsByOldReportID ?? {})) { + removeActiveTransactionIDsForReport(transactionIDs, oldReportID); + } +} + function changeTransactionsReport(props: ChangeTransactionsReportProps) { const reportID = props.newReport?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID; @@ -2188,6 +2203,7 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { }; API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, queryParameters, {optimisticData, successData, failureData}); + removeMovedTransactionsFromCarousel(loadedTransactionsOnyxData?.movedTransactionIDsByOldReportID); return; } @@ -2195,8 +2211,15 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { if (!changeTransactionsReportOnyxData) { return; } - const {optimisticData, successData, failureData, transactionIDToReportActionAndThreadData, transactionIDToUpdatedCustomUnitRateID, movedTransactionIDs} = - changeTransactionsReportOnyxData; + const { + optimisticData, + successData, + failureData, + transactionIDToReportActionAndThreadData, + transactionIDToUpdatedCustomUnitRateID, + movedTransactionIDs, + movedTransactionIDsByOldReportID, + } = changeTransactionsReportOnyxData; // If every selected transaction is already in the destination report, there is nothing to move, so skip the API call. if (movedTransactionIDs.length === 0) { @@ -2219,6 +2242,7 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { successData, failureData, }); + removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID); } /** diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index ca2835c431c9..cd18b4c2b28e 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -10,7 +10,6 @@ import usePermissions from '@hooks/usePermissions'; import {setCustomUnitID, setCustomUnitRateID} from '@libs/actions/IOU/MoneyRequest'; import {clearSubrates} from '@libs/actions/IOU/PerDiem'; import {changeTransactionsReport, setTransactionReport} from '@libs/actions/Transaction'; -import {removeActiveTransactionIDsForReport} from '@libs/actions/TransactionThreadNavigation'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -228,7 +227,6 @@ function useReportSelectionActions({ getCurrencyDecimals, getCurrencySymbol, }); - removeActiveTransactionIDsForReport(targetTransactionIDs, transaction.reportID); removeTransaction(transaction.transactionID); } }, @@ -262,7 +260,6 @@ function useReportSelectionActions({ getCurrencyDecimals, getCurrencySymbol, }); - removeActiveTransactionIDsForReport(targetTransactionIDs, transaction.reportID); removeTransaction(transaction.transactionID); }, }); diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index 3b8bccc2cc6f..a932314899fd 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -34,6 +34,7 @@ import {createCashCard} from 'tests/utils/collections/card'; import createRandomReportAction from 'tests/utils/collections/reportActions'; import {changeTransactionsReport as changeTransactionsReportAction, clearError} from '../../src/libs/actions/Transaction'; +import {CAROUSEL_SOURCE, clearActiveTransactionIDs, setActiveTransactionIDs} from '../../src/libs/actions/TransactionThreadNavigation'; import currencyList from '../unit/currencyList.json'; import createPersonalDetails from '../utils/collections/personalDetails'; import createRandomPolicy from '../utils/collections/policies'; @@ -579,6 +580,83 @@ describe('actions/Transaction', () => { expect(updatedThread?.parentReportActionID).toBe(newIOUAction?.reportActionID); }); + describe('carousel cleanup', () => { + const carouselPolicy: Policy = {...createRandomPolicy(90, CONST.POLICY.TYPE.TEAM, 'Carousel Workspace'), id: 'policy-for-carousel'}; + const sourceReport: Report = {...createRandomReport(91), reportID: '91', type: CONST.REPORT.TYPE.EXPENSE, policyID: carouselPolicy.id, ownerAccountID: CARLOS_ACCOUNT_ID}; + const destinationReport: Report = {...createRandomReport(92), reportID: '92', type: CONST.REPORT.TYPE.EXPENSE, policyID: carouselPolicy.id, ownerAccountID: CARLOS_ACCOUNT_ID}; + const buildTransaction = (transactionID: string): Transaction => ({ + transactionID, + amount: -1000, + currency: CONST.CURRENCY.USD, + merchant: 'merchant', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + comment: {comment: ''}, + reportID: sourceReport.reportID, + }); + const transactionIDs = ['carousel-transaction-1', 'carousel-transaction-2', 'carousel-transaction-3']; + + const moveFirstTransactionToDestination = async () => { + await Onyx.merge(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${carouselPolicy.id}`, carouselPolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${sourceReport.reportID}`, sourceReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${destinationReport.reportID}`, destinationReport); + for (const transactionID of transactionIDs) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, buildTransaction(transactionID)); + } + await waitForBatchedUpdates(); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, callback: (value) => (allTransactions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, callback: (value) => (allReports = value)}); + + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [transactionIDs.at(0) ?? ''], + isASAPSubmitBetaEnabled: false, + accountID: CARLOS_ACCOUNT_ID, + email: CARLOS_EMAIL, + newReport: destinationReport, + policy: carouselPolicy, + allTransactions, + policyTagList: {}, + transactionViolations: {}, + reports: allReports, + isTrackIntentUser: false, + cardList: undefined, + }); + await waitForBatchedUpdates(); + }; + + beforeEach(() => clearActiveTransactionIDs().then(waitForBatchedUpdates)); + + it('removes a moved expense from a carousel seeded from the report it left', async () => { + // Given a carousel seeded by opening an expense row in the source report, which is how the report's arrows get their list + await setActiveTransactionIDs(transactionIDs, {source: CAROUSEL_SOURCE.reportRow(sourceReport.reportID)}); + await waitForBatchedUpdates(); + + // When one of the report's expenses is moved to another report + await moveFirstTransactionToDestination(); + + // Then the moved expense is gone from the carousel, so the arrows only page through the expenses still in the report + const carouselTransactionIDs = await getOnyxValue(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + expect(carouselTransactionIDs).toEqual(transactionIDs.slice(1)); + }); + + it('leaves a carousel seeded from another source untouched', async () => { + // Given a carousel seeded from search results, which legitimately spans several reports + await setActiveTransactionIDs(transactionIDs, {source: CAROUSEL_SOURCE.search(12345)}); + await waitForBatchedUpdates(); + + // When one of the expenses is moved to another report + await moveFirstTransactionToDestination(); + + // Then the search carousel keeps every expense, since the moved one still matches the search + const carouselTransactionIDs = await getOnyxValue(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + expect(carouselTransactionIDs).toEqual(transactionIDs); + }); + }); + it('recomputes a distance expense amount/merchant/currency from the destination workspace rate when moved', async () => { // Given a destination workspace whose default distance rate is defined in GBP (200/mi) const policyID = generatePolicyID(); From d926c496c8f2dfc70ecdb48196f106cdf2ab01c9 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Wed, 14 Oct 2026 14:22:06 +0200 Subject: [PATCH 657/781] Restore label-driven domain padding and adjust stepped labels --- .../Charts/LineChart/LineChartContent.tsx | 17 +++++++------ .../Charts/components/ChartXAxisLabels.tsx | 6 +---- .../Charts/hooks/useLabelHitTesting.ts | 12 +++------- src/components/Charts/utils/index.ts | 24 ++++++------------- 4 files changed, 19 insertions(+), 40 deletions(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 2b21d7a95b04..d9e6f49bac28 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -16,7 +16,7 @@ import { useDynamicYDomain, useLabelHitTesting, } from '@components/Charts/hooks'; -import {getAxisLabelStepLayout, getDomainPaddingForEdgeSpace, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; +import {getAxisLabelStep, getDomainPaddingForEdgeSpace, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, DASH_INTERVALS, GLYPH_PADDING, LABEL_PADDING, LABEL_ROTATIONS, SIN_45, X_AXIS_LABEL_MIN_GAP} from '@components/Charts/VictoryTheme'; import useTheme from '@hooks/useTheme'; @@ -98,10 +98,11 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING; const domainPadding = (() => { - if (labelSteps || !firstLabelWidth || !lastLabelWidth) { + if (!firstLabelWidth || !lastLabelWidth) { return VictoryTheme.line.domainPadding; } - const labelsExceedTickSpacing = tickSpacing > 0 && maxLabelWidth + LABEL_PADDING > tickSpacing; + // Stepped labels are always horizontal, so they never take the diagonal overhang. + const labelsExceedTickSpacing = !labelSteps && tickSpacing > 0 && maxLabelWidth + LABEL_PADDING > tickSpacing; let leftOverhang = firstLabelWidth / 2; let rightOverhang = lastLabelWidth / 2; @@ -136,8 +137,10 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi if (!labelSteps || chartWidth <= 0 || plotAreaWidth <= 0) { return undefined; } - const pointXs = data.map((_, index) => (data.length > 1 ? boundsLeft + (index * plotAreaWidth) / (data.length - 1) : boundsLeft + plotAreaWidth / 2)); - const {step, labelXOffsets} = getAxisLabelStepLayout({labelWidths, pointXs, canvasWidth: chartWidth, steps: labelSteps, minGap: X_AXIS_LABEL_MIN_GAP}); + const firstPointX = boundsLeft + domainPadding.left * paddingScale; + const lastPointX = boundsRight - domainPadding.right * paddingScale; + const pointXs = data.map((_, index) => (data.length > 1 ? firstPointX + (index * (lastPointX - firstPointX)) / (data.length - 1) : (firstPointX + lastPointX) / 2)); + const step = getAxisLabelStep({labelWidths, pointXs, steps: labelSteps, minGap: X_AXIS_LABEL_MIN_GAP}); return { ...rotatingLabelLayout, labelRotation: LABEL_ROTATIONS.HORIZONTAL, @@ -147,13 +150,11 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi regularLabelMaxWidth: Infinity, firstLabelMaxWidth: Infinity, lastLabelMaxWidth: Infinity, - labelXOffsets, }; })(); const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = steppedLabelLayout ?? rotatingLabelLayout; - const labelXOffsets = steppedLabelLayout?.labelXOffsets; const originalLabels = data.map(getXAxisLabel); @@ -163,7 +164,6 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi truncatedLabelWidths, labelRotation, labelSkipInterval, - labelXOffsets, chartBottom, }); @@ -261,7 +261,6 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi ellipsisWidth={ellipsisWidth} labelRotation={labelRotation} labelSkipInterval={labelSkipInterval} - labelXOffsets={labelXOffsets} fontSize={variables.iconSizeExtraSmall} fontManager={fontManager} labelColor={theme.icon} diff --git a/src/components/Charts/components/ChartXAxisLabels.tsx b/src/components/Charts/components/ChartXAxisLabels.tsx index 5f843a817f06..e3a8a62dab02 100644 --- a/src/components/Charts/components/ChartXAxisLabels.tsx +++ b/src/components/Charts/components/ChartXAxisLabels.tsx @@ -33,9 +33,6 @@ type ChartXAxisLabelsProps = { /** Show every Nth label (1 = all, 2 = every other, etc.). */ labelSkipInterval: number; - /** Per-label horizontal shift (px) applied to horizontal labels */ - labelXOffsets?: number[]; - /** Font size used for rendering labels. */ fontSize: number; @@ -61,7 +58,6 @@ function ChartXAxisLabels({ ellipsisWidth, labelRotation, labelSkipInterval, - labelXOffsets, fontSize, fontManager, labelColor, @@ -108,7 +104,7 @@ function ChartXAxisLabels({ diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts index 19fec5153e44..2517f3b66b5b 100644 --- a/src/components/Charts/hooks/useLabelHitTesting.ts +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -25,9 +25,6 @@ type LabelHitGeometry = { /** Per-label: labelWidth / 2 — half-extent for 0° and 90° hit bounds */ halfWidths: number[]; - /** Per-label horizontal shift of 0° labels from their tick */ - xOffsets: number[]; - /** rightUpperCorner.x = targetX + cornerAnchorDX */ cornerAnchorDX: number; @@ -48,8 +45,6 @@ type UseLabelHitTestingParams = { truncatedLabelWidths: number[]; labelRotation: LabelRotation; labelSkipInterval: number; - /** Per-label horizontal shift (px) of 0° labels from their tick, matching ChartXAxisLabels. */ - labelXOffsets?: number[]; chartBottom: SharedValue; }; @@ -66,7 +61,7 @@ type UseLabelHitTestingParams = { * Labels are right-aligned at the tick: the 45° parallelogram's upper-right corner is * offset by (iconSize/3 * sinA) left and down, placing the box just below the axis line. */ -function useLabelHitTesting({fontManager, fontSize, truncatedLabelWidths, labelRotation, labelSkipInterval, labelXOffsets, chartBottom}: UseLabelHitTestingParams) { +function useLabelHitTesting({fontManager, fontSize, truncatedLabelWidths, labelRotation, labelSkipInterval, chartBottom}: UseLabelHitTestingParams) { const tickXPositions = useSharedValue([]); const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; @@ -85,7 +80,6 @@ function useLabelHitTesting({fontManager, fontSize, truncatedLabelWidths, labelR iconSin: variables.iconSizeExtraSmall * sinA, labelSins: truncatedLabelWidths.map((w) => w * sinA), halfWidths: truncatedLabelWidths.map((w) => w / 2), - xOffsets: truncatedLabelWidths.map((_, i) => (angleRad === 0 ? (labelXOffsets?.at(i) ?? 0) : 0)), cornerAnchorDX: -iconThirdSin, cornerAnchorDY: iconThirdSin, yMin90Offset: padding, @@ -104,7 +98,7 @@ function useLabelHitTesting({fontManager, fontSize, truncatedLabelWidths, labelR return false; } - const {labelYOffset, iconSin, labelSins, halfWidths, xOffsets, cornerAnchorDX, cornerAnchorDY, yMin90Offset, yMax90Offsets} = labelHitGeometry; + const {labelYOffset, iconSin, labelSins, halfWidths, cornerAnchorDX, cornerAnchorDY, yMin90Offset, yMax90Offsets} = labelHitGeometry; const padding = variables.iconSizeExtraSmall / 2; const halfWidth = halfWidths.at(activeIndex) ?? 0; const labelY = args.chartBottom + labelYOffset; @@ -122,7 +116,7 @@ function useLabelHitTesting({fontManager, fontSize, truncatedLabelWidths, labelR return isCursorOverChartLabel({ cursorX: args.cursorX, cursorY: args.cursorY, - targetX: args.targetX + (xOffsets.at(activeIndex) ?? 0), + targetX: args.targetX, labelY, angleRad, halfWidth, diff --git a/src/components/Charts/utils/index.ts b/src/components/Charts/utils/index.ts index 081d5a8c6259..e2fc0e38b37a 100644 --- a/src/components/Charts/utils/index.ts +++ b/src/components/Charts/utils/index.ts @@ -467,16 +467,13 @@ function getDomainPaddingForEdgeSpace(edgeSpace: {left: number; right: number}, return {left: (edgeSpace.left * plotWidth) / pointsSpan, right: (edgeSpace.right * plotWidth) / pointsSpan}; } -type AxisLabelStepLayoutParams = { +type AxisLabelStepParams = { /** Pixel width of each point's label */ labelWidths: number[]; /** Canvas x position of each point */ pointXs: number[]; - /** Canvas width the labels must stay within */ - canvasWidth: number; - /** Allowed steps between labeled points, ascending */ steps: readonly number[]; @@ -485,17 +482,11 @@ type AxisLabelStepLayoutParams = { }; /** - * Picks the smallest step whose labels (points 0, step, 2 * step, ...) don't overlap. Labels are centered under their points - * and shifted inward at the canvas edges. When no step fits, multiples of the last step are tried, down to labeling only the first point. + * Picks the smallest step whose labels (points 0, step, 2 * step, ...), centered under their points, don't overlap. + * When no step fits, multiples of the last step are tried, down to labeling only the first point. */ -function getAxisLabelStepLayout({labelWidths, pointXs, canvasWidth, steps, minGap}: AxisLabelStepLayoutParams): {step: number; labelXOffsets: number[]} { - const labelXOffsets = pointXs.map((x, i) => { - const width = labelWidths.at(i) ?? 0; - const centeredLeft = x - width / 2; - const left = Math.min(Math.max(centeredLeft, 0), Math.max(0, canvasWidth - width)); - return left - centeredLeft; - }); - const getLeft = (i: number) => (pointXs.at(i) ?? 0) + (labelXOffsets.at(i) ?? 0) - (labelWidths.at(i) ?? 0) / 2; +function getAxisLabelStep({labelWidths, pointXs, steps, minGap}: AxisLabelStepParams): number { + const getLeft = (i: number) => (pointXs.at(i) ?? 0) - (labelWidths.at(i) ?? 0) / 2; const fits = (step: number) => { for (let i = step; i < pointXs.length; i += step) { const previousRight = getLeft(i - step) + (labelWidths.at(i - step) ?? 0); @@ -511,8 +502,7 @@ function getAxisLabelStepLayout({labelWidths, pointXs, canvasWidth, steps, minGa for (let step = lastStep * 2; step < pointXs.length; step += lastStep) { candidates.push(step); } - const step = candidates.find(fits) ?? Math.max(1, pointXs.length); - return {step, labelXOffsets}; + return candidates.find(fits) ?? Math.max(1, pointXs.length); } /** Returns the pixel width needed for Y-axis labels given the chart data. */ @@ -564,7 +554,7 @@ export { getYAxisLabelWidth, getBarLayout, getDomainPaddingForEdgeSpace, - getAxisLabelStepLayout, + getAxisLabelStep, }; export type {ChartLabelHitTestParams}; From cbe26c47ab98ff833ff902184a9eca608aeaea89 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Wed, 14 Oct 2026 14:29:55 +0200 Subject: [PATCH 658/781] Add getAxisLabelStep unit tests --- tests/unit/components/Charts/utils.test.ts | 93 ++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/unit/components/Charts/utils.test.ts b/tests/unit/components/Charts/utils.test.ts index f645e8484962..5850ae539046 100644 --- a/tests/unit/components/Charts/utils.test.ts +++ b/tests/unit/components/Charts/utils.test.ts @@ -6,6 +6,7 @@ import { effectiveHeight, effectiveWidth, findSliceAtPosition, + getAxisLabelStep, getBarLayout, getAdditionalOffset, getDomainPaddingForEdgeSpace, @@ -775,3 +776,95 @@ describe('getDomainPaddingForEdgeSpace', () => { expect(getDomainPaddingForEdgeSpace({left: 30, right: 0}, 0)).toEqual({left: 30, right: 0}); }); }); + +describe('getAxisLabelStep', () => { + const MIN_GAP = 24; + + const getEvenPointXs = (count: number, spacing: number) => Array.from({length: count}, (_, index) => index * spacing); + + it('labels every point when neighboring labels keep the minimum gap', () => { + // Given 20px labels on points 50px apart, which leaves a 30px gap between neighbors + const pointXs = getEvenPointXs(5, 50); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(5).fill(20), pointXs, steps: [1, 2, 7, 14], minGap: MIN_GAP}); + + // Then the smallest step is used, so no label is hidden needlessly + expect(step).toBe(1); + }); + + it('moves to the next listed step when neighboring labels are too close', () => { + // Given 30px labels on points 50px apart, which leaves only a 20px gap between neighbors + const pointXs = getEvenPointXs(5, 50); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(5).fill(30), pointXs, steps: [1, 2, 7, 14], minGap: MIN_GAP}); + + // Then every other point is labeled, which widens the gap to 70px + expect(step).toBe(2); + }); + + it('accepts a gap exactly equal to the minimum gap', () => { + // Given 26px labels on points 50px apart, which leaves exactly a 24px gap between neighbors + const pointXs = getEvenPointXs(5, 50); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(5).fill(26), pointXs, steps: [1, 2], minGap: MIN_GAP}); + + // Then the minimum gap counts as enough room, so every point stays labeled + expect(step).toBe(1); + }); + + it('tries multiples of the last listed step when no listed step fits', () => { + // Given 30px labels on points 10px apart, too dense for steps 1 and 2 + const pointXs = getEvenPointXs(10, 10); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(10).fill(30), pointXs, steps: [1, 2], minGap: MIN_GAP}); + + // Then 4 is still too tight (10px gap), and 6 is the first multiple of 2 that leaves a 30px gap + expect(step).toBe(6); + }); + + it('labels only the first point when no step fits', () => { + // Given 3 points 10px apart with 30px labels, where even the first and last labels overlap + const pointXs = getEvenPointXs(3, 10); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(3).fill(30), pointXs, steps: [1], minGap: MIN_GAP}); + + // Then the step reaches past the last point, so only the first label is drawn instead of overlapping ones + expect(step).toBe(3); + }); + + it('labels only the first and last points when the only step spans the whole axis', () => { + // Given 5 points and the single step the narrow layout passes, points.length - 1 + const pointXs = getEvenPointXs(5, 50); + + // When picking the label step + const step = getAxisLabelStep({labelWidths: Array(5).fill(30), pointXs, steps: [4], minGap: MIN_GAP}); + + // Then that step is used, so the first and last points are labeled + expect(step).toBe(4); + }); + + it("checks each label's own width", () => { + // Given a wide first label that reaches the second label but clears the third + const pointXs = [0, 50, 100]; + + // When picking the label step + const step = getAxisLabelStep({labelWidths: [80, 20, 20], pointXs, steps: [1, 2], minGap: MIN_GAP}); + + // Then step 1 is rejected because of the first label's width, even though the narrow labels would fit + expect(step).toBe(2); + }); + + it('labels a single point with the smallest step', () => { + // Given one point, which has no neighbor its label could overlap + // When picking the label step + const step = getAxisLabelStep({labelWidths: [30], pointXs: [0], steps: [1, 2], minGap: MIN_GAP}); + + // Then the smallest step is used and the point stays labeled + expect(step).toBe(1); + }); +}); From 1a717921849575726e9b9794eebd44df13e93c5f Mon Sep 17 00:00:00 2001 From: Olgierd Date: Wed, 14 Oct 2026 14:35:39 +0200 Subject: [PATCH 659/781] Remove redundant comment --- src/components/Charts/LineChart/LineChartContent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index d9e6f49bac28..0f2f46d38c59 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -101,7 +101,6 @@ function LineChartContentBody({data, series, isLoading, yAxisUnit, yAxisUnitPosi if (!firstLabelWidth || !lastLabelWidth) { return VictoryTheme.line.domainPadding; } - // Stepped labels are always horizontal, so they never take the diagonal overhang. const labelsExceedTickSpacing = !labelSteps && tickSpacing > 0 && maxLabelWidth + LABEL_PADDING > tickSpacing; let leftOverhang = firstLabelWidth / 2; let rightOverhang = lastLabelWidth / 2; From 83c96ddcd40895d3480cb92813a226f6bf6ced90 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 12:51:00 +0000 Subject: [PATCH 660/781] Update version to 9.5.9-0 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 4 ++-- ios/NotificationServiceExtension/Info.plist | 4 ++-- ios/ShareViewController/Info.plist | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 908595d69be6..1e2ae1792635 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050805 - versionName "9.5.8-5" + versionCode 1009050900 + versionName "9.5.9-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index c7dac0e9bdaa..fe48fd5b6740 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.5.8 + 9.5.9 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.8.5 + 9.5.9.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 0327d387b87a..cb1eb32d576d 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.8 + 9.5.9 CFBundleVersion - 9.5.8.5 + 9.5.9.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 91e619206302..d003273820b7 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.8 + 9.5.9 CFBundleVersion - 9.5.8.5 + 9.5.9.0 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 96ae4a79dd75..ebb09c1e4cc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.8-5", + "version": "9.5.9-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.8-5", + "version": "9.5.9-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index ed417b921076..b523dd9455d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.8-5", + "version": "9.5.9-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 3e4911fb6f9b4de1d26d66a5cb56f951615956a1 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 12:51:00 +0000 Subject: [PATCH 661/781] Update Mobile-Expensify submodule version to 9.5.9-0 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 1fe3f8d3c912..a62bb2829c36 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 1fe3f8d3c91279fb6c94d1313e9777a442d17caa +Subproject commit a62bb2829c366ada7113e92bd29eddb8a25b4e97 From f6f98d647823588feb50ce3c31c5f9787e70fbb9 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Wed, 14 Oct 2026 15:12:00 +0200 Subject: [PATCH 662/781] Reuse getShortFormattedDayForSearch for line chart labels and rename compact fields --- src/components/Search/SearchChartView.tsx | 4 ++-- src/components/Search/SearchList/ListItem/types.ts | 8 ++++---- src/components/Search/chartGroupByConfig.ts | 6 +++--- src/libs/DateUtils.ts | 9 +-------- src/libs/SearchUIUtils.ts | 6 ++++-- tests/ui/WeekListItemHeaderTest.tsx | 2 +- tests/unit/Search/SearchUIUtilsTest.ts | 8 ++++---- tests/unit/getInProgressBucketLabelTest.ts | 4 ++-- 8 files changed, 21 insertions(+), 26 deletions(-) diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index b9e76bd8a2eb..300ab073bc5b 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -79,7 +79,7 @@ function SearchChartView({ const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const {currentSearchKey} = useSearchQueryContext(); - const {getLabel, getShortLabel, getCompactLabel, getFilterQuery, axisLabelSteps} = CHART_GROUP_BY_CONFIG[groupBy]; + const {getLabel, getShortLabel, getLineChartLabel, getFilterQuery, axisLabelSteps} = CHART_GROUP_BY_CONFIG[groupBy]; const today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING); const dateFilterRange = queryJSON ? getDateFilterRange(queryJSON) : {}; @@ -87,7 +87,7 @@ function SearchChartView({ data, view, getLabel, - getShortLabel: view === CONST.SEARCH.VIEW.LINE ? (getCompactLabel ?? getShortLabel) : getShortLabel, + getShortLabel: view === CONST.SEARCH.VIEW.LINE ? (getLineChartLabel ?? getShortLabel) : getShortLabel, getCurrencyDecimals, getInProgressLabel: (item) => getInProgressBucketLabel({groupBy, item, today, dateFnsLocale, dateFilterRange, translate}), }); diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 25bedb44c94b..69bc088aa173 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -428,11 +428,11 @@ type TransactionMemberGroupListItemType = TransactionGroupListItemType & {groupe type TransactionDayGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.DAY} & SearchDayGroup & { formattedDay: string; - /** Compact "day" value used where space is tight, e.g. chart axis labels */ + /** Compact "day" value used where space is tight, e.g. bar chart axis labels, adding the year when the groups span several years */ shortFormattedDay: string; /** Day label for line chart axes, e.g. "Aug 15", adding the year only outside the current year */ - compactFormattedDay: string; + lineChartFormattedDay: string; }; type TransactionMonthGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.MONTH} & SearchMonthGroup & { @@ -473,11 +473,11 @@ type TransactionTagGroupListItemType = TransactionGroupListItemType & {groupedBy type TransactionWeekGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.WEEK} & SearchWeekGroup & { formattedWeek: string; - /** Compact "week" value used where space is tight, e.g. chart axis labels */ + /** Compact week range used where space is tight, e.g. bar chart axis labels, adding the year when the groups span several years */ shortFormattedWeek: string; /** Week start label for line chart axes, e.g. "Sep 6", adding the year only outside the current year */ - compactFormattedWeek: string; + lineChartFormattedWeek: string; }; type TransactionYearGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.YEAR} & SearchYearGroup & { diff --git a/src/components/Search/chartGroupByConfig.ts b/src/components/Search/chartGroupByConfig.ts index 235c958b4c14..3dd3c944e5dc 100644 --- a/src/components/Search/chartGroupByConfig.ts +++ b/src/components/Search/chartGroupByConfig.ts @@ -27,7 +27,7 @@ type ChartGroupByConfig = { getShortLabel?: (item: GroupedItem) => string | undefined; /** Returns the line chart axis label, or undefined to fall back to `getShortLabel` */ - getCompactLabel?: (item: GroupedItem) => string | undefined; + getLineChartLabel?: (item: GroupedItem) => string | undefined; /** Intervals, in groups, the line chart may label its x-axis at; the smallest one that fits is used */ axisLabelSteps?: readonly number[]; @@ -79,7 +79,7 @@ const CHART_GROUP_BY_CONFIG: Record = { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? item.formattedDay : ''), getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? item.shortFormattedDay : undefined), - getCompactLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? item.compactFormattedDay : undefined), + getLineChartLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? item.lineChartFormattedDay : undefined), axisLabelSteps: [1, 2, 7, 14], getFilterQuery: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? `date>=${item.day} date<=${item.day}` : ''), getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.DAY ? {start: item.day, end: item.day} : undefined), @@ -100,7 +100,7 @@ const CHART_GROUP_BY_CONFIG: Record = { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '', getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.shortFormattedWeek : undefined), - getCompactLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.compactFormattedWeek : undefined), + getLineChartLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.lineChartFormattedWeek : undefined), axisLabelSteps: [1, 2, 4], getFilterQuery: (item: GroupedItem) => { const weekItem = item as TransactionWeekGroupListItemType; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index d18243ef980b..c4a9e03bbf6e 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -1196,17 +1196,11 @@ function isDateStringInMonth(dateString: string, year: number, month: number): b return datePart >= monthStart && datePart <= monthEnd; } -/** Returns a compact day label, e.g. "Sep 15, ’26". */ +/** Returns a compact date label, e.g. "Sep 15, ’26", or "Sep 15" when the year is left out. */ function getShortFormattedDayForSearch(day: string, dateFnsLocale: DateFnsLocale | undefined, shouldShowYear = true): string { return format(parse(day, 'yyyy-MM-dd', new Date()), shouldShowYear ? 'MMM d, ’yy' : 'MMM d', {locale: dateFnsLocale}); } -/** Returns a compact date label that adds the year only outside the current year, e.g. "Sep 15" or "Dec 28, ’25". */ -function getCompactFormattedDateForSearch(date: string, dateFnsLocale: DateFnsLocale | undefined): string { - const parsedDate = parse(date, 'yyyy-MM-dd', new Date()); - return format(parsedDate, isThisYear(parsedDate) ? 'MMM d' : 'MMM d, ’yy', {locale: dateFnsLocale}); -} - /** Returns a month label, e.g. "September 2025". */ function getFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string { return format(new Date(year, month - 1, 1), 'LLLL yyyy', {locale: dateFnsLocale}); @@ -1377,7 +1371,6 @@ const DateUtils = { getWeekDateRange, isDateStringInMonth, getShortFormattedDayForSearch, - getCompactFormattedDateForSearch, getFormattedMonthForSearch, getShortFormattedMonthForSearch, getFormattedDateRangeForSearch, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index bb0164c71663..151991344c0f 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3526,6 +3526,7 @@ function getDaySections( ): [TransactionDayGroupListItemType[], number, boolean] { const daySections: Record = {}; const shouldShowShortLabelYear = doGroupsSpanMultipleYears(data); + const currentYear = new Date().getFullYear(); for (const key in data) { if (!isGroupEntry(key)) { continue; @@ -3544,7 +3545,7 @@ function getDaySections( ...dayGroup, formattedDay: DateUtils.formatToReadableString(dayGroup.day, dateFnsLocale), shortFormattedDay: DateUtils.getShortFormattedDayForSearch(dayGroup.day, dateFnsLocale, shouldShowShortLabelYear), - compactFormattedDay: DateUtils.getCompactFormattedDateForSearch(dayGroup.day, dateFnsLocale), + lineChartFormattedDay: DateUtils.getShortFormattedDayForSearch(dayGroup.day, dateFnsLocale, getYearOfDate(dayGroup.day) !== currentYear), keyForList: key, }; } @@ -3623,6 +3624,7 @@ function getWeekSections( // Years come from the week as trimmed to the date filter, so a filter starting Jan 1 doesn't count the days before it const shouldShowShortLabelYear = new Set(Object.values(weeks).flatMap(({weekStart, weekEnd}) => [getYearOfDate(weekStart), getYearOfDate(weekEnd)])).size > 1; + const currentYear = new Date().getFullYear(); for (const [key, {weekGroup, weekStart, weekEnd, transactionsQueryJSON}] of Object.entries(weeks)) { const formattedWeek = DateUtils.getFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale); const shortFormattedWeek = DateUtils.getShortFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale, shouldShowShortLabelYear); @@ -3634,7 +3636,7 @@ function getWeekSections( ...weekGroup, formattedWeek, shortFormattedWeek, - compactFormattedWeek: DateUtils.getCompactFormattedDateForSearch(weekStart, dateFnsLocale), + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch(weekStart, dateFnsLocale, getYearOfDate(weekStart) !== currentYear), keyForList: key, }; } diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx index 6588f4b61781..c6c770c9c876 100644 --- a/tests/ui/WeekListItemHeaderTest.tsx +++ b/tests/ui/WeekListItemHeaderTest.tsx @@ -75,7 +75,7 @@ const createWeekListItem = (week: string, options: Partial { groupedBy: CONST.SEARCH.GROUP_BY.DAY, formattedDay: 'September 15, 2026', shortFormattedDay: 'Sep 15, ’26', - compactFormattedDay: 'Sep 15', + lineChartFormattedDay: 'Sep 15', transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2026-09-15', @@ -4277,7 +4277,7 @@ describe('SearchUIUtils', () => { groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Jan 25 - Jan 31, 2026', shortFormattedWeek: 'Jan 25 - 31, ’26', - compactFormattedWeek: DateUtils.getCompactFormattedDateForSearch('2026-01-25', undefined), + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch('2026-01-25', undefined, new Date().getFullYear() !== 2026), transactions: [], transactionsQueryJSON: undefined, keyForList: '2026-01-25-01-25', @@ -4878,7 +4878,7 @@ describe('SearchUIUtils', () => { groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Jan 25 - Jan 31, 2026', shortFormattedWeek: 'Jan 25 - 31, ’26', - compactFormattedWeek: DateUtils.getCompactFormattedDateForSearch('2026-01-25', undefined), + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch('2026-01-25', undefined, new Date().getFullYear() !== 2026), transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2026-01-25', @@ -4891,7 +4891,7 @@ describe('SearchUIUtils', () => { groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Dec 21 - Dec 27, 2025', shortFormattedWeek: 'Dec 21 - 27, ’25', - compactFormattedWeek: DateUtils.getCompactFormattedDateForSearch('2025-12-21', undefined), + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch('2025-12-21', undefined, new Date().getFullYear() !== 2025), transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2025-12-21', diff --git a/tests/unit/getInProgressBucketLabelTest.ts b/tests/unit/getInProgressBucketLabelTest.ts index 6b5c44ee0ba5..659d11e06654 100644 --- a/tests/unit/getInProgressBucketLabelTest.ts +++ b/tests/unit/getInProgressBucketLabelTest.ts @@ -29,7 +29,7 @@ const weekGroup = (week: string): GroupedItem => { week, formattedWeek: DateUtils.getFormattedDateRangeForSearch(start, end, undefined), shortFormattedWeek: DateUtils.getShortFormattedDateRangeForSearch(start, end, undefined), - compactFormattedWeek: DateUtils.getCompactFormattedDateForSearch(start, undefined), + lineChartFormattedWeek: DateUtils.getShortFormattedDayForSearch(start, undefined, false), }; }; const quarterGroup = (year: number, quarter: number): GroupedItem => ({ @@ -48,7 +48,7 @@ const dayGroup = (day: string): GroupedItem => ({ day, formattedDay: DateUtils.formatToReadableString(day, undefined), shortFormattedDay: DateUtils.getShortFormattedDayForSearch(day, undefined), - compactFormattedDay: DateUtils.getCompactFormattedDateForSearch(day, undefined), + lineChartFormattedDay: DateUtils.getShortFormattedDayForSearch(day, undefined, false), }); const merchantGroup = (merchant: string): GroupedItem => ({...BASE, groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, merchant, formattedMerchant: merchant}); From 2425699972eb74f565dfa1c2b45da15f7b87e670 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 14 Oct 2026 15:17:05 +0200 Subject: [PATCH 663/781] fix: name untitled dynamic form list entries and keep nested sensitive answers out of drafts --- .../DynamicForm/adapters/ListFieldAdapter.tsx | 21 +++++----- .../components/DynamicFormFieldRow.tsx | 4 +- .../components/DynamicFormFields.tsx | 3 +- .../components/DynamicFormListItemPage.tsx | 15 +++++--- src/components/DynamicForm/index.tsx | 10 ++++- .../DynamicForm/renderers/listRenderer.tsx | 3 +- .../DynamicForm/utils/getConfirmationItems.ts | 6 +-- .../DynamicForm/utils/hasSensitiveAnswer.ts | 10 +++++ src/components/DynamicForm/utils/listItems.ts | 6 +-- .../DynamicForm/utils/summarizeListItem.ts | 18 ++++++--- src/stories/DynamicForm.stories.tsx | 17 +++++++-- src/types/onyx/DynamicFormField.ts | 5 +++ .../DynamicForm/summarizeListItemTest.ts | 38 ++++++++++++++++++- 13 files changed, 116 insertions(+), 40 deletions(-) create mode 100644 src/components/DynamicForm/utils/hasSensitiveAnswer.ts diff --git a/src/components/DynamicForm/adapters/ListFieldAdapter.tsx b/src/components/DynamicForm/adapters/ListFieldAdapter.tsx index 07913d5c6537..b65a6c9dba15 100644 --- a/src/components/DynamicForm/adapters/ListFieldAdapter.tsx +++ b/src/components/DynamicForm/adapters/ListFieldAdapter.tsx @@ -1,7 +1,7 @@ import DynamicFormListItemModal from '@components/DynamicForm/components/DynamicFormListItemModal'; import type {DynamicFormValues} from '@components/DynamicForm/types'; import getAnswerKeys from '@components/DynamicForm/utils/getAnswerKeys'; -import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; +import hasSensitiveAnswer from '@components/DynamicForm/utils/hasSensitiveAnswer'; import summarizeListItem from '@components/DynamicForm/utils/summarizeListItem'; import ListField from '@components/ListField'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -37,10 +37,10 @@ type ListFieldAdapterProps = Pick, 'addTitle' | /** Validation error shown under the list */ errorText?: string; - /** Titles the modal editor of an entry whose answers give it no name */ + /** Names an entry whose answers give it no name, when the list has no `itemLabel` */ label: string; - /** What one entry is, such as "owner", for the remove confirmation */ + /** What one entry is, such as "owner", for the remove confirmation and for an entry whose answers give it no name */ itemLabel?: string; /** The fields of one entry, which name and describe its row */ @@ -62,7 +62,7 @@ type ListFieldAdapterProps = Pick, 'addTitle' | renderFields: (fields: DynamicFormField[], values: DynamicFormValues, isSensitive: boolean) => ReactNode; }; -/** The entries of a list field as rows. Removing one updates FormProvider's value; adding and editing open the flow's editor page, or a modal outside the flow. */ +/** The entries of a list field as rows. Removing one updates FormProvider's value. Adding and editing open the flow's editor page, or a modal outside the flow. */ function ListFieldAdapter({ value, onInputChange = () => {}, @@ -85,12 +85,9 @@ function ListFieldAdapter({ const [editingID, setEditingID] = useState(); const items = Array.isArray(value) ? value : []; const editingItem = items.find((item) => item.id === editingID); - const editingTitle = editingItem ? summarizeListItem(editingItem, itemFields, translate).title : ''; - let modalTitle = addTitle; - if (editingItem) { - modalTitle = editingTitle === '' ? label : editingTitle; - } - const sensitiveKeys = new Set(itemFields.filter(isSensitiveField).flatMap(getAnswerKeys)); + const summarize = (item: DynamicFormListItem) => summarizeListItem(item, itemFields, translate, itemLabel ?? label); + const modalTitle = editingItem ? summarize(editingItem).title : addTitle; + const sensitiveKeys = new Set(itemFields.filter(hasSensitiveAnswer).flatMap(getAnswerKeys)); const isKeptOutOfDraft = (key: string) => key !== 'id' && (isSensitive || sensitiveKeys.has(key)); // Sensitive answers are not drafted, so the modal's inputs start from the stored entry instead. Every answer of a sensitive list is. @@ -124,7 +121,7 @@ function ListFieldAdapter({ title: itemLabel ? translate('dynamicForm.removeItemTitle', {item: itemLabel}) : translate('common.remove'), prompt: ( - {translate('dynamicForm.removeItemPrompt', {name: summarizeListItem(item, itemFields, translate).title})} + {translate('dynamicForm.removeItemPrompt', {name: summarize(item).title})} ), confirmText: translate('common.remove'), @@ -146,7 +143,7 @@ function ListFieldAdapter({ ({id: item.id, ...summarizeListItem(item, itemFields, translate)}))} + rows={items.map((item) => ({id: item.id, ...summarize(item)}))} canAddMore={maxItems === undefined || items.length < maxItems} errorText={errorText} onAdd={onAdd ?? (() => openModal())} diff --git a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx index edf9a0f07f99..44c4a88c9f19 100644 --- a/src/components/DynamicForm/components/DynamicFormFieldRow.tsx +++ b/src/components/DynamicForm/components/DynamicFormFieldRow.tsx @@ -4,7 +4,7 @@ import formatDynamicFieldValue from '@components/DynamicForm/utils/formatDynamic import getLocalizedText, {getFieldLabel} from '@components/DynamicForm/utils/getLocalizedText'; import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; import {getListItems} from '@components/DynamicForm/utils/listItems'; -import summarizeListItem from '@components/DynamicForm/utils/summarizeListItem'; +import summarizeListItem, {getListItemFallbackTitle} from '@components/DynamicForm/utils/summarizeListItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; @@ -57,7 +57,7 @@ function DynamicFormFieldRow({ const readonlyValue = field.type === 'list' ? getListItems(values[field.key]) - .map((item) => summarizeListItem(item, field.itemFields, translate).title) + .map((item) => summarizeListItem(item, field.itemFields, translate, getListItemFallbackTitle(field, translate)).title) .join(', ') : formatDynamicFieldValue(field, values, translate); return ( diff --git a/src/components/DynamicForm/components/DynamicFormFields.tsx b/src/components/DynamicForm/components/DynamicFormFields.tsx index ab4944ff55fb..2a68e053ef34 100644 --- a/src/components/DynamicForm/components/DynamicFormFields.tsx +++ b/src/components/DynamicForm/components/DynamicFormFields.tsx @@ -34,7 +34,7 @@ type DynamicFormFieldsProps = { /** Called when the user changes a field marked `refreshOnChange`, with the draft key that changed, so the screen can fetch the schema again */ onRefreshRequirements?: (inputID: string, value: FormValue) => void; - /** Opens the editor page of a list entry, or of a new one without `itemID`. DynamicFormFlow passes it; without it list entries are edited in a modal. */ + /** Opens the editor page of a list entry, or of a new one without `itemID`. DynamicFormFlow passes it. Without it, list entries are edited in a modal. */ onOpenListItemEditor?: (listKey: string, itemID?: string) => void; /** Called with a list's entries after the user removes one, so the flow can keep a sensitive list in memory up to date */ @@ -66,6 +66,7 @@ function DynamicFormFields({ isSensitive={isItemSensitive} currency={currency} maxFileSize={maxFileSize} + onRefreshRequirements={onRefreshRequirements} /> ); diff --git a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx index 3c5d4563f2df..460a865eee2e 100644 --- a/src/components/DynamicForm/components/DynamicFormListItemPage.tsx +++ b/src/components/DynamicForm/components/DynamicFormListItemPage.tsx @@ -1,12 +1,12 @@ +import ActivityIndicator from '@components/ActivityIndicator'; import type {DynamicFormSubPageProps} from '@components/DynamicForm/types'; import getDynamicFieldErrors from '@components/DynamicForm/utils/getDynamicFieldErrors'; import getLocalizedText from '@components/DynamicForm/utils/getLocalizedText'; import isSensitiveField from '@components/DynamicForm/utils/isSensitiveField'; import {getListItems, getListItemSensitiveAnswers, parseListItemPageName} from '@components/DynamicForm/utils/listItems'; -import summarizeListItem from '@components/DynamicForm/utils/summarizeListItem'; +import summarizeListItem, {getListItemFallbackTitle} from '@components/DynamicForm/utils/summarizeListItem'; import toDynamicFormValues from '@components/DynamicForm/utils/toDynamicFormValues'; import FormProvider from '@components/Form/FormProvider'; -import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -19,6 +19,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {DynamicFormListField} from '@src/types/onyx'; import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; import DynamicFormFields from './DynamicFormFields'; @@ -52,16 +53,20 @@ function DynamicFormListItemPage({currentPageName, allFields, values, currency, if (!page || !listField) { return null; } + // The flow's header stays above this page, so a spinner in the page is enough while the editor fills if (filledPageName !== currentPageName) { - return ; + return ( + + + + ); } const sensitiveAnswers = item ? getListItemSensitiveAnswers(listField, item.id, values) : {}; const keptAnswers = isSensitiveList ? {...item, ...sensitiveAnswers} : sensitiveAnswers; const withInputValues = (inputValues: unknown) => ({...keptAnswers, ...toDynamicFormValues(inputValues)}); const itemLabel = getLocalizedText(translate, listField.itemLabelKey, listField.itemLabel); const addTitle = itemLabel ? translate('dynamicForm.addItem', {item: itemLabel}) : translate('common.add'); - const itemTitle = item ? summarizeListItem(item, listField.itemFields, translate).title : ''; - const title = itemTitle === '' ? addTitle : itemTitle; + const title = item ? summarizeListItem(item, listField.itemFields, translate, getListItemFallbackTitle(listField, translate)).title : addTitle; return ( ); diff --git a/src/types/onyx/DynamicFormField.ts b/src/types/onyx/DynamicFormField.ts index 0a5fa5257b05..a7d805ce48e7 100644 --- a/src/types/onyx/DynamicFormField.ts +++ b/src/types/onyx/DynamicFormField.ts @@ -51,6 +51,11 @@ type DynamicFormFieldBase = { descriptionKey?: TranslationPaths; + /** The headline of a page with no title of its own where this is the only question, such as "What's your account number?". It also names that page's section on the confirmation page. */ + question?: string; + + questionKey?: TranslationPaths; + required: boolean; /** Never written to the form draft, for SSNs and account numbers */ diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx index ce78ca052cec..8fd0294b3b45 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx @@ -90,10 +90,10 @@ type RenderFieldsOptions = { currency?: string; maxFileSize?: number; onRefreshRequirements?: (inputID: string, value: unknown) => void; - isTitledByPage?: boolean; + isLoneFieldInHeadline?: boolean; }; -function renderFields(content: DynamicFormContentItem[], values: DynamicFormValues = {}, {currency, maxFileSize, onRefreshRequirements, isTitledByPage}: RenderFieldsOptions = {}) { +function renderFields(content: DynamicFormContentItem[], values: DynamicFormValues = {}, {currency, maxFileSize, onRefreshRequirements, isLoneFieldInHeadline}: RenderFieldsOptions = {}) { mockInputWrapper.mockClear(); render( , ); return new Map(mockInputWrapper.mock.calls.map(([props]) => [props.inputID, props])); @@ -606,7 +606,7 @@ describe('DynamicFormFields', () => { const field: DynamicFormField = {key: 'answer', label: 'The question', type, required: true}; // When it renders - const rendered = renderFields([field], {}, {isTitledByPage: true}); + const rendered = renderFields([field], {}, {isLoneFieldInHeadline: true}); // Then the choice is drawn as the page itself, keeping its kind of answer, and the label is left to the page title instead of repeating it expect(rendered.get('answer')?.InputComponent).toBe(ExpectedInput); @@ -671,7 +671,7 @@ describe('DynamicFormFields', () => { const companyName: DynamicFormField = {key: 'companyName', type: 'text', required: true, showWhen: {key: 'legalType', equals: ['BUSINESS']}}; // When it renders for a business recipient on a page without its own title - const rendered = renderFields([legalType, companyName], {legalType: 'BUSINESS'}, {isTitledByPage: true}); + const rendered = renderFields([legalType, companyName], {legalType: 'BUSINESS'}, {isLoneFieldInHeadline: true}); // Then it is still a tab row rather than an inline list, since the schema asked for tabs, and the question is left to the page title expect(rendered.get('legalType')?.InputComponent).toBe(TabsAdapter); diff --git a/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx index 0ac0d69d6b7a..e9e220d3d8ff 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFlowTest.tsx @@ -260,25 +260,29 @@ describe('DynamicFormFlow', () => { mockStepWrapper.mockClear(); await renderFlow({draft, layout: 'pages'}); - // Then the default layout names every page and highlights the current one, and plain pages have no indicator - expect(autoLayout).toEqual(expect.objectContaining({stepNames: ['Personal details', 'Recipient', 'Company'], startStepIndex: 1})); + // Then the default layout has a step per page and highlights the current one, and plain pages have no indicator + expect(autoLayout).toEqual(expect.objectContaining({stepNames: ['personal-details', 'recipient', 'company'], startStepIndex: 1})); expect(mockStepWrapper).not.toHaveBeenCalled(); }); - it('names the step of a page with no title after its field', async () => { - // Given pages built from a flat list, which carry no title, shown with the stepper + it('keys the steps of pages with no title by their page keys', async () => { + // Given pages built from a flat list, which carry no title, where two pages ask several questions and so have no headline either const flowSchema: DynamicFormSchema = { fields: [ {key: 'home', type: 'address', required: true, label: 'Home address'}, + {key: 'firstName', type: 'text', required: true, label: 'First name'}, + {key: 'lastName', type: 'text', required: true, label: 'Last name'}, {key: 'work', type: 'address', required: true, label: 'Work address'}, + {key: 'company', type: 'text', required: true, label: 'Company'}, + {key: 'role', type: 'text', required: true, label: 'Role'}, ], }; - // When the flow renders + // When the flow renders with the stepper await renderFlow({flowSchema, layout: 'stepper', currentPageName: 'page-1'}); - // Then no step is blank - expect(mockStepWrapper.mock.lastCall?.[0].stepNames).toEqual(['Home address', 'Work address']); + // Then every step has a distinct name, since the indicator keys its steps by them + expect(mockStepWrapper.mock.lastCall?.[0].stepNames).toEqual(['page-1', 'page-2', 'page-3', 'page-4']); }); it('starts a new form on its first page that has something to ask', async () => { diff --git a/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts b/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts index bb0cdc45d2ae..8005d857d06f 100644 --- a/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts +++ b/tests/unit/components/DynamicForm/getConfirmationItemsTest.ts @@ -58,6 +58,26 @@ describe('getConfirmationItems', () => { expect(groups.map((group) => group.id)).toEqual(['identity']); }); + it('leaves the section of an untitled page with several questions unnamed', () => { + // Given a page the form built from a flat list, so it has no title, asking an account number and a currency + const untitledPages: DynamicFormPage[] = [ + { + key: 'page-1', + content: [ + {key: 'accountNumber', type: 'text', required: true, label: 'Account number'}, + {key: 'currency', type: 'currency', required: true, label: 'Settlement currency'}, + ], + }, + ]; + + // When the confirmation sections are built + const [group] = getConfirmationItems(untitledPages, getAllFields(untitledPages), {accountNumber: '12345678', currency: 'GBP'}, translateLocal, jest.fn(), jest.fn()); + + // Then the section has no name, since naming it after its first answer would mislabel the others + expect(group.name).toBe(''); + expect(group.rows.map((row) => row.id)).toEqual(['accountNumber', 'currency']); + }); + it('shows each list entry as its own row that opens the entry', () => { // Given a page with a list of directors holding one entry const directorsPages: DynamicFormPage[] = [ diff --git a/tests/unit/components/DynamicForm/paginateTest.ts b/tests/unit/components/DynamicForm/paginateTest.ts index c690adc3eae6..a64f765a43b4 100644 --- a/tests/unit/components/DynamicForm/paginateTest.ts +++ b/tests/unit/components/DynamicForm/paginateTest.ts @@ -1,4 +1,4 @@ -import getPageTitle, {isPageShown} from '@components/DynamicForm/utils/getPageTitle'; +import getPageTitle, {getPageSectionName, isPageShown} from '@components/DynamicForm/utils/getPageTitle'; import paginate, {getAllFields, getPages} from '@components/DynamicForm/utils/paginate'; import type {DynamicFormField, DynamicFormPage, DynamicFormSchemaField, DynamicFormTextField} from '@src/types/onyx'; @@ -145,15 +145,62 @@ describe('getPageTitle', () => { expect(title).toBe('Alpha'); }); - it('falls back to the first visible field label when there are several questions', () => { - // Given an untitled page whose first field is hidden + it("prefers the lone question's wording over its label", () => { + // Given an untitled page whose only question has its wording as a question + const page: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Account number', question: "What's your account number?"})]}; + + // When its title is read + const title = getPageTitle(page, {}, translateLocal); + + // Then the headline asks the question, so it does not repeat the input's label + expect(title).toBe("What's your account number?"); + }); + + it('has no title when there are several questions', () => { + // Given an untitled page with several visible questions const page: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Alpha', showWhen: {key: 'z', equals: ['1']}}), text('b', {label: 'Beta'}), text('c', {label: 'Gamma'})]}; // When its title is read const title = getPageTitle(page, {}, translateLocal); - // Then the title is the first field the user sees - expect(title).toBe('Beta'); + // Then it is blank, since no one question names the page + expect(title).toBe(''); + }); +}); + +describe('getPageSectionName', () => { + it('uses the page title', () => { + // Given a titled page + const page: DynamicFormPage = {key: 'p', title: 'Bank details', content: [text('a', {label: 'Alpha'})]}; + + // When its section name is read + const name = getPageSectionName(page, {}, translateLocal); + + // Then it is the page title + expect(name).toBe('Bank details'); + }); + + it("uses the lone question's wording on an untitled page", () => { + // Given an untitled page whose only question has its wording + const page: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Account number', question: "What's your account number?"})]}; + + // When its section name is read + const name = getPageSectionName(page, {}, translateLocal); + + // Then it is that wording + expect(name).toBe("What's your account number?"); + }); + + it('never uses a label', () => { + // Given untitled pages with one question and with several, none with its wording + const lonePage: DynamicFormPage = {key: 'p', content: [text('a', {label: 'Account number'})]}; + const sharedPage: DynamicFormPage = {key: 'q', content: [text('a', {label: 'Account number'}), text('b', {label: 'Settlement currency'})]}; + + // When their section names are read + const names = [getPageSectionName(lonePage, {}, translateLocal), getPageSectionName(sharedPage, {}, translateLocal)]; + + // Then both are blank, since the label is already on the row below and would only name one of several rows + expect(names).toEqual(['', '']); }); }); From 6e17675b5628b5e7960330647296b57a3c22023d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 14 Oct 2026 20:18:29 +0530 Subject: [PATCH 673/781] Keep the Find expense input visible with the keyboard open in landscape on Add existing expense Signed-off-by: krishna2323 --- .../Tables/AddExistingExpenseTable/index.tsx | 15 +++++++--- src/pages/AddExistingExpense.tsx | 28 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/components/Tables/AddExistingExpenseTable/index.tsx b/src/components/Tables/AddExistingExpenseTable/index.tsx index 15d5902fee76..72ef7960ceda 100644 --- a/src/components/Tables/AddExistingExpenseTable/index.tsx +++ b/src/components/Tables/AddExistingExpenseTable/index.tsx @@ -20,14 +20,22 @@ type UnreportedExpenseTableRowData = Transaction & TableData; type AddExistingExpenseTableProps = Pick< TableProps, - 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent' + 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent' | 'shouldFooterRenderAsLastRow' > & { data: UnreportedExpenseTableRowData[]; selectedKeys: string[]; onRowSelectionChange: (selectedRowKeys: string[]) => void; }; -function AddExistingExpenseTable({data, selectedKeys, onRowSelectionChange, onEndReached, onEndReachedThreshold, ListFooterComponent}: AddExistingExpenseTableProps) { +function AddExistingExpenseTable({ + data, + selectedKeys, + onRowSelectionChange, + onEndReached, + onEndReachedThreshold, + ListFooterComponent, + shouldFooterRenderAsLastRow, +}: AddExistingExpenseTableProps) { const {translate, localeCompare} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); @@ -123,8 +131,7 @@ function AddExistingExpenseTable({data, selectedKeys, onRowSelectionChange, onEn shouldAlwaysEnableSelection // Expenses are confirmed in one batch, so searching or filtering must not drop what is already picked. shouldPreserveSelectionOnSearchAndFilter - // The skeleton appended while the next page loads continues the rows, so it draws the bottom corners. - shouldFooterRenderAsLastRow + shouldFooterRenderAsLastRow={shouldFooterRenderAsLastRow} selectedKeys={selectedKeys} onRowSelectionChange={onRowSelectionChange} initialSortColumn="date" diff --git a/src/pages/AddExistingExpense.tsx b/src/pages/AddExistingExpense.tsx index b9ce3a383a96..f484c12c8b6d 100644 --- a/src/pages/AddExistingExpense.tsx +++ b/src/pages/AddExistingExpense.tsx @@ -1,4 +1,5 @@ import AddExistingExpenseFooter from '@components/AddExistingExpenseFooter'; +import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import EmptyStateComponent from '@components/EmptyStateComponent'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -15,6 +16,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import {fetchUnreportedExpenses} from '@libs/actions/UnreportedExpenses'; @@ -69,6 +71,7 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { const [allOpenReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: openExpenseReportIDsSelector}); const [openReportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT, {selector: openExpenseReportIDsSelector}); const isInLandscapeMode = useIsInLandscapeMode(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); const transactionsSelector = useCallback( @@ -199,10 +202,12 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { testID="NewChatSelectorPage" focusTrapSettings={{active: false}} > - + + + + {paginationFooterContent} + {footerContent} + + ) : ( + paginationFooterContent + ) + } + // The skeleton appended while the next page loads continues the rows, so it draws the bottom corners. + shouldFooterRenderAsLastRow={!!paginationFooterContent} /> - {footerContent} + {!shouldFooterBeInsideList && footerContent} ); } From c56c0d5380d97d7534449be321f516ee5633d955 Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 14 Oct 2026 19:49:23 +0500 Subject: [PATCH 674/781] Note that upstream declined a fix for the Skia sizing patch The upstream issue was closed as an app concern and neither upstream PR is the fix, so the patch stays across Skia upgrades. --- patches/@shopify/react-native-skia/details.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index b4741c128c44..d09ae410b58d 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -156,9 +156,9 @@ Untransformed canvases keep the previous ratio, so nothing else changes. ``` -- Upstream PR/issue: +- Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/4084 was closed as not a Skia bug: the maintainer considers a canvas painted under a CSS transform an app concern, and said neither https://github.com/Shopify/react-native-skia/pull/4085 (this approach) nor https://github.com/Shopify/react-native-skia/pull/4105 (an opt-in pixelDensity prop) is the way to fix it. No upstream fix is planned, so this patch has to be carried across Skia upgrades unless the charts stop being fitted with a transform. - E/App issue: https://github.com/Expensify/App/issues/95221 -- PR introducing patch: +- PR introducing patch: https://github.com/Expensify/App/pull/101291 ### [@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch](@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch) From 00bf5e78e28a5dbca90d352b0061cef18c13a80a Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 14 Oct 2026 16:52:48 +0200 Subject: [PATCH 675/781] fix: align dynamic form select threshold, add flat schema story and document schema fields --- contributingGuides/DYNAMIC_FORMS.md | 2 +- src/CONST/index.ts | 2 +- .../DynamicForm/renderers/choiceRenderers.tsx | 9 ++- src/stories/DynamicForm.stories.tsx | 58 +++++++++++++++---- src/types/onyx/DynamicFormField.ts | 37 ++++++++++++ .../DynamicForm/DynamicFormFieldsTest.tsx | 12 ++++ 6 files changed, 102 insertions(+), 18 deletions(-) diff --git a/contributingGuides/DYNAMIC_FORMS.md b/contributingGuides/DYNAMIC_FORMS.md index 22e4d41c1b24..6ffae2d5ea1c 100644 --- a/contributingGuides/DYNAMIC_FORMS.md +++ b/contributingGuides/DYNAMIC_FORMS.md @@ -78,7 +78,7 @@ A page is shown while its own `showWhen` matches and it has a visible field; a p | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `text` | `TextInput` | `regex`, `minLength`, `maxLength`, `example`, `keyboard`, `multiline`, `rule: 'legalName' \| 'phone'` | | `number` | `TextInput` with the numeric keyboard | `regex`, `minLength`, `maxLength`, `example` | -| `select` | `ValuePicker`, or `PushRowWithModal` above 8 options | `values`, `dependsOn`, `presentation: 'tabs'` | +| `select` | `ValuePicker`, or `PushRowWithModal` above 10 options | `values`, `dependsOn`, `presentation: 'tabs'` | | `radio` | `RadioButtons` | `values`, `dependsOn`, `presentation: 'tabs'` | | `multiselect` | `PushRowWithModal` with checkboxes and a Save button | `values`, `dependsOn` | | `date` | `DatePicker` | `rule: 'dateOfBirth'` | diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2b42939ff377..7c83ee2cd074 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -971,7 +971,7 @@ const CONST = { FILE_LIMIT: 1, }, DYNAMIC_FORM: { - /** A select with more options than this sits on its own page, since it opens a searchable list */ + /** A select with more options than this opens a searchable list, so it sits on its own page */ LARGE_SELECT_MIN_OPTIONS: 10, /** Most questions sharing a page when pages are built from a flat list of fields */ diff --git a/src/components/DynamicForm/renderers/choiceRenderers.tsx b/src/components/DynamicForm/renderers/choiceRenderers.tsx index f695779917dd..749726ba79f4 100644 --- a/src/components/DynamicForm/renderers/choiceRenderers.tsx +++ b/src/components/DynamicForm/renderers/choiceRenderers.tsx @@ -13,6 +13,8 @@ import type {Choice} from '@components/RadioButtons'; import RadioButtons from '@components/RadioButtons'; import ValuePicker from '@components/ValuePicker'; +import CONST from '@src/CONST'; + import React from 'react'; import type {DynamicFieldInput, DynamicFieldInputProps, DynamicFieldRenderer, DynamicFieldRendererMap} from './types'; @@ -22,9 +24,6 @@ type InlineChoiceOptions = { isSearchable: boolean; }; -/** Above this many options a choice opens a searchable list instead of showing every option at once */ -const LONG_CHOICE_LIST_THRESHOLD = 8; - function getChoiceOptionsList(choices: Choice[]): Record { return Object.fromEntries(choices.map((choice) => [choice.value, choice.label])); } @@ -71,7 +70,7 @@ const renderMultiChoice: DynamicFieldRenderer<'multiselect' | 'countryMultiselec const label = getFieldLabel(field, translate); const choices = getFieldChoices(field, values, translate); if (isLoneField) { - return renderInlineChoice(label, choices, inputProps, {canSelectMultiple: true, isSearchable: choices.length > LONG_CHOICE_LIST_THRESHOLD}); + return renderInlineChoice(label, choices, inputProps, {canSelectMultiple: true, isSearchable: choices.length > CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS}); } return { isMenuRow: true, @@ -95,7 +94,7 @@ const CHOICE_RENDERERS: Pick { const label = getFieldLabel(field, translate); const choices = getFieldChoices(field, values, translate); - const isLong = choices.length > LONG_CHOICE_LIST_THRESHOLD; + const isLong = choices.length > CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS; if (field.presentation === 'tabs') { return renderTabs(label, choices, inputProps, isLoneField); } diff --git a/src/stories/DynamicForm.stories.tsx b/src/stories/DynamicForm.stories.tsx index c4e8ea2e0ef1..30b0ec1534a4 100644 --- a/src/stories/DynamicForm.stories.tsx +++ b/src/stories/DynamicForm.stories.tsx @@ -34,8 +34,11 @@ const FLOW_ROUTE_PREFIX = 'settings/wallet/add-bank-account'; /** A fixture field with the title of the page it is asked on */ type GroupedField = DynamicFormSchemaField & {group: string}; -/** Each run of fields sharing a group becomes one page titled by it, as the server sends pages */ -function toSchema(fields: GroupedField[]): DynamicFormSchema { +/** Each run of fields sharing a group becomes one page titled by it, as the server sends pages. A flat schema drops the groups and lets the form build untitled pages. */ +function toSchema(fields: GroupedField[], isFlat = false): DynamicFormSchema { + if (isFlat) { + return {fields: fields.map(({group, ...field}) => field)}; + } const pages: DynamicFormPage[] = []; for (const {group, ...field} of fields) { const lastPage = pages.at(-1); @@ -54,6 +57,9 @@ type Preset = { /** Answers seeded into the form draft before the first render */ draftValues: Record; + + /** Sends the fields as a flat `{fields}` schema, ignoring `group`, so the form paginates them itself */ + isFlat?: boolean; }; /** A bank account form spread over several pages */ @@ -304,6 +310,16 @@ const PRESETS = { fields: [...accountDetails, ...accountHolder], draftValues: {legalType: 'PRIVATE', numberOfEmployees: '25'}, }, + flatFields: { + // The address is alone on its page, so its question heads that page + fields: [ + ...accountDetails, + ...accountHolder.map((field) => (field.key === 'address' ? {...field, question: "What's your home address?"} : field)), + {key: 'ownsQuarter', label: 'Do you own 25% or more?', group: 'Account holder', type: 'boolean', required: true}, + ], + draftValues: {legalType: 'PRIVATE', numberOfEmployees: '25'}, + isFlat: true, + }, businessOnboarding: { fields: [...businessInfo, ...dueDiligence, owners], draftValues: {legalBusinessName: 'Boulder Development Inc.'}, @@ -367,11 +383,12 @@ function useSeededDraft(draftValues: Preset['draftValues']): boolean { } /** Every page on one screen, for looking at the renderer rather than the flow */ -function SinglePage({fields, draftValues}: Preset) { +function SinglePage({fields, draftValues, isFlat}: Preset) { const {translate} = useLocalize(); const isDraftReady = useSeededDraft(draftValues); // List entries are edited on flow pages, so this screen leaves lists out - const pages = getPages(toSchema(fields.filter((field) => field.type !== 'list'))); + const pageFields = fields.filter((field) => field.type !== 'list'); + const pages = getPages(toSchema(pageFields, isFlat)); const allFields = getAllFields(pages); if (!isDraftReady) { @@ -414,7 +431,7 @@ type FlowProps = Preset & { }; /** The whole flow on a story-owned route stack: pages, resume, the entry editor pages and the confirmation are all clickable */ -function Flow({fields, draftValues, layout, confirmation, resetKey}: FlowProps) { +function Flow({fields, draftValues, isFlat, layout, confirmation, resetKey}: FlowProps) { const isDraftReady = useSeededDraft(draftValues); if (!isDraftReady) { return ; @@ -429,7 +446,7 @@ function Flow({fields, draftValues, layout, confirmation, resetKey}: FlowProps) routePrefix={FLOW_ROUTE_PREFIX} > & { }; function FlowFromPreset({preset, layout, confirmation}: FlowStoryProps) { - const {fields, draftValues} = PRESETS[preset]; + const presetValues: Preset = PRESETS[preset]; return ( @@ -499,7 +517,7 @@ type PlaygroundProps = Preset & { }; /** Editable schema: pick a preset, then change the field JSON in the Controls panel and watch the form follow */ -function Playground({preset, fields, draftValues, layout}: PlaygroundProps) { +function Playground({preset, fields, draftValues, isFlat, layout}: PlaygroundProps) { const [, updateArgs] = useArgs(); useEffect(() => { updateArgs(PRESETS[preset]); @@ -513,6 +531,7 @@ function Playground({preset, fields, draftValues, layout}: PlaygroundProps) { key={resetKey} fields={schema} draftValues={draftValues ?? {}} + isFlat={isFlat} /> ); } @@ -521,6 +540,7 @@ function Playground({preset, fields, draftValues, layout}: PlaygroundProps) { resetKey={resetKey} fields={schema} draftValues={draftValues ?? {}} + isFlat={isFlat} layout="auto" confirmation="auto" /> @@ -537,6 +557,7 @@ PlaygroundStory.argTypes = { preset: {options: PRESET_NAMES, control: {type: 'select'}}, fields: {control: {type: 'object'}}, draftValues: {control: {type: 'object'}}, + isFlat: {control: {type: 'boolean'}}, layout: {options: ['flow', 'single'], control: {type: 'radio'}}, }; @@ -544,6 +565,7 @@ const AllFieldTypes: StoryFn = SinglePage.bind({}); AllFieldTypes.args = {fields: [...accountDetails, ...accountHolder, ...businessInfo], draftValues: {legalType: 'BUSINESS', legalBusinessName: 'Boulder Development Inc.'}}; const BankAccount = presetFlowStory('bankAccount'); +const FlatFields = presetFlowStory('flatFields'); const BusinessOnboarding = presetFlowStory('businessOnboarding'); const OwnersList = presetFlowStory('owners'); const LoneMultiselect = presetFlowStory('loneMultiselect'); @@ -554,4 +576,18 @@ const AmountWithCurrency = presetFlowStory('amountWithCurrency'); const UnknownType = presetFlowStory('unknownType'); export default story; -export {FlowStory, PlaygroundStory, AllFieldTypes, BankAccount, BusinessOnboarding, OwnersList, LoneMultiselect, YesNoQuestion, LoneCountry, LoneCurrency, AmountWithCurrency, UnknownType}; +export { + FlowStory, + PlaygroundStory, + AllFieldTypes, + BankAccount, + FlatFields, + BusinessOnboarding, + OwnersList, + LoneMultiselect, + YesNoQuestion, + LoneCountry, + LoneCurrency, + AmountWithCurrency, + UnknownType, +}; diff --git a/src/types/onyx/DynamicFormField.ts b/src/types/onyx/DynamicFormField.ts index a7d805ce48e7..c79faf501381 100644 --- a/src/types/onyx/DynamicFormField.ts +++ b/src/types/onyx/DynamicFormField.ts @@ -7,7 +7,10 @@ import type {InputModeOptions} from 'react-native'; /** Met while the answer to `key` is one of `equals` */ type DynamicFormCondition = { + /** Key of the field whose answer is checked */ key: string; + + /** Answers that meet the condition. A boolean answer matches as `'true'` or `'false'`, and a list answer matches when any chosen value is listed. */ equals: string[]; }; @@ -19,11 +22,13 @@ type DynamicFormHeading = { /** The schema author's wording, shown when there is no titleKey */ title?: string; + /** Translation of `title`, used instead of it when set */ titleKey?: TranslationPaths; /** The schema author's wording, shown when there is no descriptionKey */ description?: string; + /** Translation of `description`, used instead of it when set */ descriptionKey?: TranslationPaths; /** Hides the title only. The fields after it still show, since a heading marks a place rather than containing fields. */ @@ -44,18 +49,22 @@ type DynamicFormFieldBase = { /** The schema author's wording, shown when there is no labelKey */ label?: string; + /** Translation of `label`, used instead of it when set */ labelKey?: TranslationPaths; /** Supporting text, such as which format is accepted */ description?: string; + /** Translation of `description`, used instead of it when set */ descriptionKey?: TranslationPaths; /** The headline of a page with no title of its own where this is the only question, such as "What's your account number?". It also names that page's section on the confirmation page. */ question?: string; + /** Translation of `question`, used instead of it when set */ questionKey?: TranslationPaths; + /** The form cannot move on until the field is answered */ required: boolean; /** Never written to the form draft, for SSNs and account numbers */ @@ -73,19 +82,28 @@ type DynamicFormFieldBase = { /** Free text, checked by regex, length and an optional named rule */ type DynamicFormTextField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'text'; + + /** Pattern the answer must match, anchored by the schema when the whole answer must fit. An invalid pattern is reported and skipped, so it never blocks the user. */ regex?: string; + + /** Fewest characters the answer can have */ minLength?: number; + + /** Most characters the answer can have. The input stops taking more. */ maxLength?: number; /** Sample value, shown as a hint and in the format error */ example?: string; + /** Keyboard the input opens. Without it, `rule: 'phone'` opens the phone keyboard and a digits-only `regex` the numeric one. */ keyboard?: Extract; /** Grows with its content, for descriptions */ multiline?: boolean; + /** A named check on top of the others: `legalName` for a person's legal name, `phone` for an international phone number */ rule?: 'legalName' | 'phone'; }; @@ -94,7 +112,10 @@ type DynamicFormNumberField = DynamicFormFieldBase & Pick; }; }; @@ -112,12 +136,16 @@ type DynamicFormMultiChoiceField = DynamicFormFieldBase & Pick.`. */ type DynamicFormAddressField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'address'; /** Checks the zip against the format of the chosen country */ @@ -126,6 +154,7 @@ type DynamicFormAddressField = DynamicFormFieldBase & { /** Uploaded documents */ type DynamicFormFileField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'file'; /** One file when not set */ @@ -137,6 +166,7 @@ type DynamicFormFileField = DynamicFormFieldBase & { /** A money amount */ type DynamicFormAmountField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'amount'; /** Draft key of the currency the user picks next to the amount. Without it the currency is fixed by the screen. */ @@ -145,11 +175,13 @@ type DynamicFormAmountField = DynamicFormFieldBase & { /** One country, or any number of countries, picked from every country the App knows */ type DynamicFormCountryField = DynamicFormFieldBase & { + /** `country` picks one country, `countryMultiselect` any number of them */ type: 'country' | 'countryMultiselect'; }; /** A Yes/No question, where No is an answer too, or a checkbox that must be ticked when required */ type DynamicFormBooleanField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'boolean'; /** `yesNo`, the default, asks a question that No answers. `checkbox` asks for consent, such as accepting terms, which only a tick gives. */ @@ -158,6 +190,7 @@ type DynamicFormBooleanField = DynamicFormFieldBase & { /** Repeated entries, such as a company's owners, each answered with its own fields on an editor page */ type DynamicFormListField = DynamicFormFieldBase & { + /** Tells the field types apart */ type: 'list'; /** The fields of one entry */ @@ -184,6 +217,7 @@ type DynamicFormListField = DynamicFormFieldBase & { /** Types that need nothing beyond the common properties */ type DynamicFormPlainField = DynamicFormFieldBase & { + /** `currency` picks a currency code, `percent` takes a percentage from 1 to 100 */ type: 'currency' | 'percent'; }; @@ -204,7 +238,10 @@ type DynamicFormField = /** One entry of a list field: its answers keyed by item field key, and an `id` made on the device to key the entry's row and editor page */ type DynamicFormListItem = { + /** Made on the device when the entry is added, and keys the entry's row, editor page and sensitive answers */ id: string; + + /** The entry's answers, keyed by item field key */ [fieldKey: string]: FormValue | undefined; }; diff --git a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx index 8fd0294b3b45..8b0438eedf0f 100644 --- a/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx +++ b/tests/unit/components/DynamicForm/DynamicFormFieldsTest.tsx @@ -268,6 +268,18 @@ describe('DynamicFormFields', () => { expect(Object.keys(rendered.get('industry')?.optionsList ?? {})).toHaveLength(options.length); }); + it('keeps a select as a picker up to the number of options a shared page takes', () => { + // Given a select with as many options as pagination still lets share a page, next to another question + const options = Array.from({length: CONST.DYNAMIC_FORM.LARGE_SELECT_MIN_OPTIONS}, (_, index) => ({key: `option${index}`, label: `Option ${index}`})); + const accountType: DynamicFormField = {key: 'accountType', label: 'Account type', type: 'select', required: true, values: options}; + + // When the fields render + const rendered = renderFields([accountType, otherQuestion]); + + // Then it stays a picker, since a searchable list is kept for selects that pagination puts on their own page + expect(rendered.get('accountType')?.InputComponent).toBe(ValuePicker); + }); + it('hints a text field with its example when it has no description', () => { // Given a text field with an example and no description const accountNumber: DynamicFormField = {key: 'accountNumber', type: 'text', required: true, example: '12345678'}; From 7fb1cb177c513a049103aac5435c0650ff8ca728 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 14 Oct 2026 20:23:22 +0530 Subject: [PATCH 676/781] Let the time picker and spreadsheet import pages scroll so Save and Choose file stay reachable in landscape Signed-off-by: krishna2323 --- src/components/ImportSpreadsheet.tsx | 53 +++++++++++++++------------- src/components/TimeModalPicker.tsx | 10 ++++-- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx index 0eadf12bd75a..ea11f5046a3a 100644 --- a/src/components/ImportSpreadsheet.tsx +++ b/src/components/ImportSpreadsheet.tsx @@ -33,6 +33,7 @@ import HeaderWithBackButton from './HeaderWithBackButton'; import ImageSVG from './ImageSVG'; import RenderHTML from './RenderHTML'; import ScreenWrapper from './ScreenWrapper'; +import ScrollView from './ScrollView'; import Text from './Text'; type ImportSpreadsheetProps = { @@ -285,33 +286,35 @@ function ImportSpreadsheet({backTo, goTo, shouldForceReplaceNavigation = false, }} /> - - {!(isDraggingOver ?? isDraggingOver) && desktopView} + + + {!(isDraggingOver ?? isDraggingOver) && desktopView} - { - const file = e?.dataTransfer?.files[0]; - if (!file || isReadingFile) { - return; - } - readFile(file); - }} - > - - - - {translate('common.dropTitle')} - {translate('common.dropMessage')} + { + const file = e?.dataTransfer?.files[0]; + if (!file || isReadingFile) { + return; + } + readFile(file); + }} + > + + + + {translate('common.dropTitle')} + {translate('common.dropMessage')} + - - - + + + )} diff --git a/src/components/TimeModalPicker.tsx b/src/components/TimeModalPicker.tsx index 9c0078fafab2..c7ee63ce6f36 100644 --- a/src/components/TimeModalPicker.tsx +++ b/src/components/TimeModalPicker.tsx @@ -6,14 +6,15 @@ import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import type {ComponentRef, ForwardedRef} from 'react'; +import type {View} from 'react-native'; import React, {useState} from 'react'; -import {View} from 'react-native'; import HeaderWithBackButton from './HeaderWithBackButton'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import Modal from './Modal'; import ScreenWrapper from './ScreenWrapper'; +import ScrollView from './ScrollView'; import TimePicker from './TimePicker/TimePicker'; type TimeModalPickerProps = { @@ -75,13 +76,16 @@ function TimeModalPicker({value, errorText, label, onInputChange = () => {}, ref title={label} onBackButtonPress={hidePickerModal} /> - + - + From 08171c6b787a0370a849ad132708d8cdf7e96d87 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Wed, 14 Oct 2026 10:53:48 -0400 Subject: [PATCH 677/781] Stop syncing the test-staging route on deploy wrangler looks up the zone before syncing routes, which the CI deploy token cannot do, so the deploy job failed after the Worker was already live. The route stays in place in Cloudflare; wrangler only replaces routes when some are configured. --- web/cloudflare-worker/README.md | 4 ++-- web/cloudflare-worker/wrangler.jsonc | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index 240e22d88fcd..7cbb1b0cefdd 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -107,11 +107,11 @@ The script diffs status, caching and security headers for a fixed set of paths. | Environment | Worker | Route | CSP | | --- | --- | --- | --- | -| `test-staging` | `test-staging-new-expensify-assets` | `test-staging.new.expensify.com/*` | Staging | +| `test-staging` | `test-staging-new-expensify-assets` | `test-staging.new.expensify.com/*` (managed in Cloudflare, not in `wrangler.jsonc`) | Staging | | `staging` | `staging-new-expensify-assets` | None until switchover, then `staging.new.expensify.com/*` | Staging | | `production` | `new-expensify-assets` | None until switchover, then `new.expensify.com/*` | Production | -`test-staging` serves a staging build on a hostname nobody else uses. Every staging deploy also deploys the same build there (the `webDeployTestStaging` job in `.github/workflows/deploy.yml`), using `CLOUDFLARE_WORKERS_DEPLOY_TOKEN`, which can only edit this Worker. A failure there does not fail the staging deploy. To deploy it by hand, put a staging build in `dist/` and run: +`test-staging` serves a staging build on a hostname nobody else uses. Every staging deploy also deploys the same build there (the `webDeployTestStaging` job in `.github/workflows/deploy.yml`), using `CLOUDFLARE_WORKERS_DEPLOY_TOKEN`, which can only edit this Worker. That token can't read zones, so the route isn't in `wrangler.jsonc`: wrangler would try to sync it and fail. A failure there does not fail the staging deploy. To deploy it by hand, put a staging build in `dist/` and run: ```bash npx wrangler deploy --env test-staging diff --git a/web/cloudflare-worker/wrangler.jsonc b/web/cloudflare-worker/wrangler.jsonc index 4a96b6d90679..611081288528 100644 --- a/web/cloudflare-worker/wrangler.jsonc +++ b/web/cloudflare-worker/wrangler.jsonc @@ -28,9 +28,10 @@ }, "env": { // Serves a staging build on a hostname nobody else uses, so it can be deployed and tested independently. + // Its route (test-staging.new.expensify.com/*) is managed in Cloudflare, not here: syncing routes needs zone + // access, and the CI deploy token can only edit this Worker. "test-staging": { "name": "test-staging-new-expensify-assets", - "routes": [{"pattern": "test-staging.new.expensify.com/*", "zone_name": "expensify.com"}], "vars": { "ENVIRONMENT": "staging", }, From 37732e21bd048f5bab57267b0792f78a49a2df03 Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Wed, 14 Oct 2026 15:07:54 +0000 Subject: [PATCH 678/781] Restore moved expenses to the carousel when the move request fails Co-authored-by: Eskalifer1 --- src/libs/actions/Transaction.ts | 32 ++++++++++++----- .../actions/TransactionThreadNavigation.ts | 35 +++++++++++++------ tests/actions/TransactionTest.ts | 16 +++++++++ .../TransactionThreadNavigationTest.ts | 34 +++++++++++++----- 4 files changed, 90 insertions(+), 27 deletions(-) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index ce7c21bc10ee..a61d2435c64b 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -90,7 +90,7 @@ import lodashClone from 'lodash/clone'; import Onyx from 'react-native-onyx'; import {getSearchOnyxUpdate} from './IOU/SearchUpdate'; -import {removeActiveTransactionIDsForReport} from './TransactionThreadNavigation'; +import {getRemoveActiveTransactionIDsForReportOnyxData} from './TransactionThreadNavigation'; type SaveWaypointProps = { transactionID: string; @@ -2142,13 +2142,22 @@ function getChangeTransactionsReportOnyxData({ } /** - * Drops moved expenses from a carousel seeded from the report they left, so its arrows stop paging to them. + * Builds the Onyx updates that drop moved expenses from a carousel seeded from the report they left, so its arrows stop + * paging to them. They go in the move's optimistic and failure data, so a failed move puts the expenses back. * Every move goes through `changeTransactionsReport`, so doing it here covers all entry points at once. */ -function removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID: Record | undefined) { +function getMovedTransactionsCarouselOnyxData(movedTransactionIDsByOldReportID: Record | undefined) { + const optimisticData: Array> = []; + const failureData: Array> = []; for (const [oldReportID, transactionIDs] of Object.entries(movedTransactionIDsByOldReportID ?? {})) { - removeActiveTransactionIDsForReport(transactionIDs, oldReportID); + const carouselOnyxData = getRemoveActiveTransactionIDsForReportOnyxData(transactionIDs, oldReportID); + if (!carouselOnyxData) { + continue; + } + optimisticData.push(...carouselOnyxData.optimisticData); + failureData.push(...carouselOnyxData.failureData); } + return {optimisticData, failureData}; } function changeTransactionsReport(props: ChangeTransactionsReportProps) { @@ -2202,8 +2211,12 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { hash: props.hash, }; - API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, queryParameters, {optimisticData, successData, failureData}); - removeMovedTransactionsFromCarousel(loadedTransactionsOnyxData?.movedTransactionIDsByOldReportID); + const carouselOnyxData = getMovedTransactionsCarouselOnyxData(loadedTransactionsOnyxData?.movedTransactionIDsByOldReportID); + API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, queryParameters, { + optimisticData: [...optimisticData, ...carouselOnyxData.optimisticData], + successData, + failureData: [...failureData, ...carouselOnyxData.failureData], + }); return; } @@ -2235,14 +2248,15 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { }), }; + const carouselOnyxData = getMovedTransactionsCarouselOnyxData(movedTransactionIDsByOldReportID); + // The all-matching path above already returned after its own API.write, so only one of the two writes ever runs // eslint-disable-next-line rulesdir/no-multiple-api-calls API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, parameters, { - optimisticData, + optimisticData: [...optimisticData, ...carouselOnyxData.optimisticData], successData, - failureData, + failureData: [...failureData, ...carouselOnyxData.failureData], }); - removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID); } /** diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 82fbf8c0756f..d037e89f9062 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -2,6 +2,8 @@ import type {TransactionThreadNavigationDescriptor} from '@libs/TransactionThrea import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxUpdate} from 'react-native-onyx'; + import Onyx from 'react-native-onyx'; /** @@ -201,36 +203,49 @@ function clearActiveTransactionIDsForSource(source: string) { return clearActiveTransactionIDs(); } +type RemoveActiveTransactionIDsOnyxData = { + optimisticData: Array>; + failureData: Array>; +}; + /** - * Takes expenses that just left `reportID` out of a carousel seeded from that report. + * Builds the Onyx updates that take expenses leaving `reportID` out of a carousel seeded from that report. * * A report-scoped carousel lists the report's own expenses, so an expense moved or removed from the report no longer * belongs in it. Leaving it there kept the arrows on the report's header after it dropped to one expense, and * stepping to the moved expense opened it in its new home (for example the self-DM). Carousels from other screens, * such as search results, legitimately span several reports and are left alone. + * + * The updates ride along with the move request, so a failed move puts the expense back into the carousel together + * with putting it back into the report. The owner is kept even when the list empties, so it can still clear a list + * that a failed move restored. */ -function removeActiveTransactionIDsForReport(transactionIDs: string[], reportID: string | undefined) { +function getRemoveActiveTransactionIDsForReportOnyxData(transactionIDs: string[], reportID: string | undefined): RemoveActiveTransactionIDsOnyxData | undefined { if (!lastSetIDs?.length || !transactionIDs.length || !reportID) { - return Promise.resolve(); + return undefined; } const reportSources = new Set([CAROUSEL_SOURCE.report(reportID), CAROUSEL_SOURCE.reportRow(reportID), CAROUSEL_SOURCE.reportPreview(reportID)]); if (!lastSetSource || !reportSources.has(lastSetSource)) { - return Promise.resolve(); + return undefined; } const removedIDs = new Set(transactionIDs); const remainingIDs = lastSetIDs.filter((transactionID) => !removedIDs.has(transactionID)); if (remainingIDs.length === lastSetIDs.length) { - return Promise.resolve(); + return undefined; } + const previousIDs = lastSetIDs; + // One expense left is nothing to page through. - if (remainingIDs.length < 2) { - return clearActiveTransactionIDs(); - } + const nextIDs = remainingIDs.length < 2 ? null : remainingIDs; + lastSetIDs = nextIDs; - return setActiveTransactionIDs(remainingIDs, {source: lastSetSource, snapshotHash: lastSetSnapshotHash ?? undefined, descriptors: lastSetDescriptors ?? undefined}); + return { + optimisticData: [{onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, value: nextIDs}], + failureData: [{onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, value: previousIDs}], + }; } /** @@ -253,7 +268,7 @@ export { setActiveTransactionIDs, clearActiveTransactionIDs, clearActiveTransactionIDsForSource, - removeActiveTransactionIDsForReport, + getRemoveActiveTransactionIDsForReportOnyxData, disownActiveTransactionIDs, getActiveTransactionIDs, shouldRefreshActiveTransactionIDs, diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index a932314899fd..825d7c9111e4 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -643,6 +643,22 @@ describe('actions/Transaction', () => { expect(carouselTransactionIDs).toEqual(transactionIDs.slice(1)); }); + it('puts the expense back into the carousel when the move fails', async () => { + // Given a carousel seeded by opening an expense row in the source report + await setActiveTransactionIDs(transactionIDs, {source: CAROUSEL_SOURCE.reportRow(sourceReport.reportID)}); + await waitForBatchedUpdates(); + + // When one of the report's expenses is moved but the request fails, which returns the expense to the report + const mockFetch = getGlobalFetchMock(); + mockFetch.fail(); + global.fetch = mockFetch; + await moveFirstTransactionToDestination(); + + // Then the expense is back in the carousel too, so the report's arrows page through it again + const carouselTransactionIDs = await getOnyxValue(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + expect(carouselTransactionIDs).toEqual(transactionIDs); + }); + it('leaves a carousel seeded from another source untouched', async () => { // Given a carousel seeded from search results, which legitimately spans several reports await setActiveTransactionIDs(transactionIDs, {source: CAROUSEL_SOURCE.search(12345)}); diff --git a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts index 3e1f8fc3b452..1df5d51c5537 100644 --- a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts +++ b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts @@ -4,7 +4,7 @@ import { clearActiveTransactionIDsForSource, disownActiveTransactionIDs, getActiveTransactionIDs, - removeActiveTransactionIDsForReport, + getRemoveActiveTransactionIDsForReportOnyxData, setActiveTransactionIDs, shouldRefreshActiveTransactionIDs, } from '@libs/actions/TransactionThreadNavigation'; @@ -286,17 +286,20 @@ describe('TransactionThreadNavigation carousel ownership', () => { * report left it in the report's carousel, so the report kept its arrows and stepping to the removed expense * opened the self-DM. */ - describe('removeActiveTransactionIDsForReport', () => { + describe('getRemoveActiveTransactionIDsForReportOnyxData', () => { it('clears a report row carousel that drops to one expense', async () => { // Given an expense opened from report B's list, seeding the carousel with both of B's expenses - await setActiveTransactionIDs(REPORT_B_IDS, {source: CAROUSEL_SOURCE.reportRow('B')}); + const reportRowSource = CAROUSEL_SOURCE.reportRow('B'); + await setActiveTransactionIDs(REPORT_B_IDS, {source: reportRowSource}); // When one of them is removed from report B - await removeActiveTransactionIDsForReport(['B1'], 'B'); + await Onyx.update(getRemoveActiveTransactionIDsForReportOnyxData(['B1'], 'B')?.optimisticData ?? []); // Then there is nothing left to page through, so the carousel is cleared and no arrows show - expect(getActiveTransactionIDs()).toEqual({ids: null, descriptors: null, source: null, snapshotHash: null}); expect((await readCarouselFromOnyx()).ids).toBeUndefined(); + + // And the report row still owns it, so it can clear a list a failed move restores + expect(getActiveTransactionIDs()).toEqual({ids: null, descriptors: null, source: reportRowSource, snapshotHash: null}); }); it('keeps paging through the expenses still on the report', async () => { @@ -304,21 +307,35 @@ describe('TransactionThreadNavigation carousel ownership', () => { await setActiveTransactionIDs(['B1', 'B2', 'B3'], {source: REPORT_SOURCE}); // When one expense moves to another report - await removeActiveTransactionIDsForReport(['B2'], 'B'); + await Onyx.update(getRemoveActiveTransactionIDsForReportOnyxData(['B2'], 'B')?.optimisticData ?? []); // Then the other two stay in the carousel under the same owner expect(getActiveTransactionIDs()).toEqual({ids: ['B1', 'B3'], descriptors: null, source: REPORT_SOURCE, snapshotHash: null}); expect((await readCarouselFromOnyx()).ids).toEqual(['B1', 'B3']); }); + it('restores the expense when the move fails', async () => { + // Given report B's own carousel with three expenses, one of which was just moved out + await setActiveTransactionIDs(['B1', 'B2', 'B3'], {source: REPORT_SOURCE}); + const onyxData = getRemoveActiveTransactionIDsForReportOnyxData(['B2'], 'B'); + await Onyx.update(onyxData?.optimisticData ?? []); + + // When the move request fails, which returns the expense to report B + await Onyx.update(onyxData?.failureData ?? []); + + // Then the expense is back in the carousel, so the arrows page through it again + expect((await readCarouselFromOnyx()).ids).toEqual(['B1', 'B2', 'B3']); + }); + it('leaves a search carousel alone, since it spans several reports', async () => { // Given a carousel seeded from the Spend page, which holds expenses from reports A and B await setActiveTransactionIDs(SPEND_PAGE_IDS, {source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); // When an expense is removed from report B - await removeActiveTransactionIDsForReport(['B1'], 'B'); + const onyxData = getRemoveActiveTransactionIDsForReportOnyxData(['B1'], 'B'); // Then the search carousel is untouched, because the expense is still one of its results + expect(onyxData).toBeUndefined(); expect(getActiveTransactionIDs()).toEqual({ids: SPEND_PAGE_IDS, descriptors: null, source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); }); @@ -327,9 +344,10 @@ describe('TransactionThreadNavigation carousel ownership', () => { await setActiveTransactionIDs(REPORT_B_IDS, {source: REPORT_SOURCE}); // When an expense leaves a different report - await removeActiveTransactionIDsForReport(['B1'], 'A'); + const onyxData = getRemoveActiveTransactionIDsForReportOnyxData(['B1'], 'A'); // Then report B's carousel is untouched + expect(onyxData).toBeUndefined(); expect(getActiveTransactionIDs()).toEqual({ids: REPORT_B_IDS, descriptors: null, source: REPORT_SOURCE, snapshotHash: null}); }); }); From 4ed1f37b21b92186692f59f66e540584910fdda0 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 15:30:17 +0000 Subject: [PATCH 679/781] Update Mobile-Expensify submodule to 7b114fa --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index a62bb2829c36..7b114fac24e4 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit a62bb2829c366ada7113e92bd29eddb8a25b4e97 +Subproject commit 7b114fac24e4a28701357a7ea37c3e9091c5b312 From 7ab79943dbb2fc02cc2c1c457b5146b28aa16889 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Wed, 14 Oct 2026 11:41:54 -0400 Subject: [PATCH 680/781] Remove useless Onyx.connect causing lint failures --- tests/unit/ReportUtilsGetIconsTest.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts index 5edd49674bfb..9f20280483bf 100644 --- a/tests/unit/ReportUtilsGetIconsTest.ts +++ b/tests/unit/ReportUtilsGetIconsTest.ts @@ -124,9 +124,6 @@ beforeAll(() => { ...FAKE_POLICIES, }, }); - // @ts-expect-error Until we add NVP_PRIVATE_DOMAINS to ONYXKEYS, we need to mock it here - - Onyx.connect({key: ONYXKEYS.NVP_PRIVATE_DOMAINS, callback: () => {}}); }); describe('getIcons', () => { From e55d3756d8f65921aa64c2b5388a12c778092a11 Mon Sep 17 00:00:00 2001 From: Carlos Miceli Date: Wed, 14 Oct 2026 13:11:08 -0300 Subject: [PATCH 681/781] fix: allow empty last name after workspace join Last name is optional in personal details, so requiring it after joining a workspace traps users on the workspace list even though the join succeeds. --- .../BaseOnboardingWorkspaces.tsx | 2 +- tests/ui/WorkspaceOnboarding.tsx | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 9429a15ed586..3831fb29c6f5 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -157,7 +157,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingAdminsChatReportID(); setOnboardingPolicyID(policy.policyID); - if (isJoiningCompanyWorkspace && (!onboardingPersonalDetails?.firstName.trim() || !onboardingPersonalDetails.lastName.trim())) { + if (isJoiningCompanyWorkspace && !onboardingPersonalDetails?.firstName.trim()) { Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); return; } diff --git a/tests/ui/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index 7d0b8c2560ef..bd1f9254dcd0 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -709,6 +709,40 @@ describe('OnboardingWorkspaces Page', () => { await waitForBatchedUpdatesWithAct(); }); + it('should complete onboarding after joining when only the required first name is present', async () => { + // Given a Join Workspace user who supplied the required first name and left the optional last name blank. + const policyID = 'joined-policy-id'; + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, {firstName: 'Test', lastName: ''}); + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, { + [policyID]: { + policyID, + policyName: 'Joined workspace', + policyOwner: 'owner@example.com', + employeeCount: 1, + hasPendingAccess: false, + automaticJoiningEnabled: true, + policyType: CONST.POLICY.TYPE.CORPORATE, + }, + }); + }); + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + await waitForBatchedUpdatesWithAct(); + + // When the user joins the workspace. + fireEvent.press(screen.getByText(TestHelper.translateLocal('workspace.workspaceList.joinNow'))); + + // Then onboarding completes instead of trying to reopen the personal-details step. + expect(mockCompleteOnboarding).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should finish the marked Join Workspace flow when the merged account has a different persisted intent', async () => { // Given a merge resumed from a Join Workspace task while the target account still persists a different intent. const dismissModalWithReport = jest.spyOn(Navigation, 'dismissModalWithReport').mockImplementation(() => {}); From 123d57b352e2800b2a068868ab7bde89a32cea0b Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Wed, 14 Oct 2026 16:13:23 +0000 Subject: [PATCH 682/781] Trim the carousel directly instead of through the move's Onyx data The failure rollback could write the old list back after the expense's header had already cleared it, leaving an orphaned list that blocks the single-expense redirect. It also left the module state trimmed while Onyx was restored, so a retry skipped the trim. Write Onyx and the module state together instead. Co-authored-by: Eskalifer1 --- src/libs/actions/Transaction.ts | 34 +++++---------- .../actions/TransactionThreadNavigation.ts | 42 ++++++------------- tests/actions/TransactionTest.ts | 7 ++-- .../TransactionThreadNavigationTest.ts | 38 ++++++++--------- 4 files changed, 43 insertions(+), 78 deletions(-) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index a61d2435c64b..2b7e8a53407b 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -90,7 +90,7 @@ import lodashClone from 'lodash/clone'; import Onyx from 'react-native-onyx'; import {getSearchOnyxUpdate} from './IOU/SearchUpdate'; -import {getRemoveActiveTransactionIDsForReportOnyxData} from './TransactionThreadNavigation'; +import {removeActiveTransactionIDsForReport} from './TransactionThreadNavigation'; type SaveWaypointProps = { transactionID: string; @@ -2142,22 +2142,13 @@ function getChangeTransactionsReportOnyxData({ } /** - * Builds the Onyx updates that drop moved expenses from a carousel seeded from the report they left, so its arrows stop - * paging to them. They go in the move's optimistic and failure data, so a failed move puts the expenses back. - * Every move goes through `changeTransactionsReport`, so doing it here covers all entry points at once. + * Drops moved expenses from a carousel seeded from the report they left. Every move goes through + * `changeTransactionsReport`, so doing it here covers all entry points at once. */ -function getMovedTransactionsCarouselOnyxData(movedTransactionIDsByOldReportID: Record | undefined) { - const optimisticData: Array> = []; - const failureData: Array> = []; +function removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID: Record | undefined) { for (const [oldReportID, transactionIDs] of Object.entries(movedTransactionIDsByOldReportID ?? {})) { - const carouselOnyxData = getRemoveActiveTransactionIDsForReportOnyxData(transactionIDs, oldReportID); - if (!carouselOnyxData) { - continue; - } - optimisticData.push(...carouselOnyxData.optimisticData); - failureData.push(...carouselOnyxData.failureData); + removeActiveTransactionIDsForReport(transactionIDs, oldReportID); } - return {optimisticData, failureData}; } function changeTransactionsReport(props: ChangeTransactionsReportProps) { @@ -2211,12 +2202,8 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { hash: props.hash, }; - const carouselOnyxData = getMovedTransactionsCarouselOnyxData(loadedTransactionsOnyxData?.movedTransactionIDsByOldReportID); - API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, queryParameters, { - optimisticData: [...optimisticData, ...carouselOnyxData.optimisticData], - successData, - failureData: [...failureData, ...carouselOnyxData.failureData], - }); + API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, queryParameters, {optimisticData, successData, failureData}); + removeMovedTransactionsFromCarousel(loadedTransactionsOnyxData?.movedTransactionIDsByOldReportID); return; } @@ -2248,15 +2235,14 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { }), }; - const carouselOnyxData = getMovedTransactionsCarouselOnyxData(movedTransactionIDsByOldReportID); - // The all-matching path above already returned after its own API.write, so only one of the two writes ever runs // eslint-disable-next-line rulesdir/no-multiple-api-calls API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, parameters, { - optimisticData: [...optimisticData, ...carouselOnyxData.optimisticData], + optimisticData, successData, - failureData: [...failureData, ...carouselOnyxData.failureData], + failureData, }); + removeMovedTransactionsFromCarousel(movedTransactionIDsByOldReportID); } /** diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index d037e89f9062..226885d42762 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -2,8 +2,6 @@ import type {TransactionThreadNavigationDescriptor} from '@libs/TransactionThrea import ONYXKEYS from '@src/ONYXKEYS'; -import type {OnyxUpdate} from 'react-native-onyx'; - import Onyx from 'react-native-onyx'; /** @@ -203,49 +201,33 @@ function clearActiveTransactionIDsForSource(source: string) { return clearActiveTransactionIDs(); } -type RemoveActiveTransactionIDsOnyxData = { - optimisticData: Array>; - failureData: Array>; -}; - /** - * Builds the Onyx updates that take expenses leaving `reportID` out of a carousel seeded from that report. - * - * A report-scoped carousel lists the report's own expenses, so an expense moved or removed from the report no longer - * belongs in it. Leaving it there kept the arrows on the report's header after it dropped to one expense, and - * stepping to the moved expense opened it in its new home (for example the self-DM). Carousels from other screens, - * such as search results, legitimately span several reports and are left alone. - * - * The updates ride along with the move request, so a failed move puts the expense back into the carousel together - * with putting it back into the report. The owner is kept even when the list empties, so it can still clear a list - * that a failed move restored. + * Drops expenses that left `reportID` from a carousel seeded from that report, so its arrows stop paging to them. + * Carousels from other screens, such as search results, span several reports and are left alone. */ -function getRemoveActiveTransactionIDsForReportOnyxData(transactionIDs: string[], reportID: string | undefined): RemoveActiveTransactionIDsOnyxData | undefined { +function removeActiveTransactionIDsForReport(transactionIDs: string[], reportID: string | undefined) { if (!lastSetIDs?.length || !transactionIDs.length || !reportID) { - return undefined; + return Promise.resolve(); } const reportSources = new Set([CAROUSEL_SOURCE.report(reportID), CAROUSEL_SOURCE.reportRow(reportID), CAROUSEL_SOURCE.reportPreview(reportID)]); if (!lastSetSource || !reportSources.has(lastSetSource)) { - return undefined; + return Promise.resolve(); } const removedIDs = new Set(transactionIDs); const remainingIDs = lastSetIDs.filter((transactionID) => !removedIDs.has(transactionID)); if (remainingIDs.length === lastSetIDs.length) { - return undefined; + return Promise.resolve(); } - const previousIDs = lastSetIDs; - // One expense left is nothing to page through. - const nextIDs = remainingIDs.length < 2 ? null : remainingIDs; - lastSetIDs = nextIDs; + if (remainingIDs.length < 2) { + return clearActiveTransactionIDs(); + } - return { - optimisticData: [{onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, value: nextIDs}], - failureData: [{onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, value: previousIDs}], - }; + lastSetIDs = remainingIDs; + return Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, remainingIDs); } /** @@ -268,7 +250,7 @@ export { setActiveTransactionIDs, clearActiveTransactionIDs, clearActiveTransactionIDsForSource, - getRemoveActiveTransactionIDsForReportOnyxData, + removeActiveTransactionIDsForReport, disownActiveTransactionIDs, getActiveTransactionIDs, shouldRefreshActiveTransactionIDs, diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index 825d7c9111e4..9671d275c89b 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -643,7 +643,7 @@ describe('actions/Transaction', () => { expect(carouselTransactionIDs).toEqual(transactionIDs.slice(1)); }); - it('puts the expense back into the carousel when the move fails', async () => { + it('does not write the carousel back when the move fails', async () => { // Given a carousel seeded by opening an expense row in the source report await setActiveTransactionIDs(transactionIDs, {source: CAROUSEL_SOURCE.reportRow(sourceReport.reportID)}); await waitForBatchedUpdates(); @@ -654,9 +654,10 @@ describe('actions/Transaction', () => { global.fetch = mockFetch; await moveFirstTransactionToDestination(); - // Then the expense is back in the carousel too, so the report's arrows page through it again + // Then the failure leaves the carousel alone, because by the time it lands the screen that owned the list + // may already have cleared it, and writing it back would leave an orphaned list that nothing clears const carouselTransactionIDs = await getOnyxValue(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); - expect(carouselTransactionIDs).toEqual(transactionIDs); + expect(carouselTransactionIDs).toEqual(transactionIDs.slice(1)); }); it('leaves a carousel seeded from another source untouched', async () => { diff --git a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts index 1df5d51c5537..e8dfd0e9506e 100644 --- a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts +++ b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts @@ -4,7 +4,7 @@ import { clearActiveTransactionIDsForSource, disownActiveTransactionIDs, getActiveTransactionIDs, - getRemoveActiveTransactionIDsForReportOnyxData, + removeActiveTransactionIDsForReport, setActiveTransactionIDs, shouldRefreshActiveTransactionIDs, } from '@libs/actions/TransactionThreadNavigation'; @@ -286,20 +286,17 @@ describe('TransactionThreadNavigation carousel ownership', () => { * report left it in the report's carousel, so the report kept its arrows and stepping to the removed expense * opened the self-DM. */ - describe('getRemoveActiveTransactionIDsForReportOnyxData', () => { + describe('removeActiveTransactionIDsForReport', () => { it('clears a report row carousel that drops to one expense', async () => { // Given an expense opened from report B's list, seeding the carousel with both of B's expenses - const reportRowSource = CAROUSEL_SOURCE.reportRow('B'); - await setActiveTransactionIDs(REPORT_B_IDS, {source: reportRowSource}); + await setActiveTransactionIDs(REPORT_B_IDS, {source: CAROUSEL_SOURCE.reportRow('B')}); // When one of them is removed from report B - await Onyx.update(getRemoveActiveTransactionIDsForReportOnyxData(['B1'], 'B')?.optimisticData ?? []); + await removeActiveTransactionIDsForReport(['B1'], 'B'); // Then there is nothing left to page through, so the carousel is cleared and no arrows show expect((await readCarouselFromOnyx()).ids).toBeUndefined(); - - // And the report row still owns it, so it can clear a list a failed move restores - expect(getActiveTransactionIDs()).toEqual({ids: null, descriptors: null, source: reportRowSource, snapshotHash: null}); + expect(getActiveTransactionIDs()).toEqual({ids: null, descriptors: null, source: null, snapshotHash: null}); }); it('keeps paging through the expenses still on the report', async () => { @@ -307,24 +304,23 @@ describe('TransactionThreadNavigation carousel ownership', () => { await setActiveTransactionIDs(['B1', 'B2', 'B3'], {source: REPORT_SOURCE}); // When one expense moves to another report - await Onyx.update(getRemoveActiveTransactionIDsForReportOnyxData(['B2'], 'B')?.optimisticData ?? []); + await removeActiveTransactionIDsForReport(['B2'], 'B'); // Then the other two stay in the carousel under the same owner expect(getActiveTransactionIDs()).toEqual({ids: ['B1', 'B3'], descriptors: null, source: REPORT_SOURCE, snapshotHash: null}); expect((await readCarouselFromOnyx()).ids).toEqual(['B1', 'B3']); }); - it('restores the expense when the move fails', async () => { - // Given report B's own carousel with three expenses, one of which was just moved out + it('trims again on a later move, since the module state matches Onyx', async () => { + // Given report B's own carousel with three expenses, one of which was already moved out await setActiveTransactionIDs(['B1', 'B2', 'B3'], {source: REPORT_SOURCE}); - const onyxData = getRemoveActiveTransactionIDsForReportOnyxData(['B2'], 'B'); - await Onyx.update(onyxData?.optimisticData ?? []); + await removeActiveTransactionIDsForReport(['B2'], 'B'); - // When the move request fails, which returns the expense to report B - await Onyx.update(onyxData?.failureData ?? []); + // When a second expense moves out of report B + await removeActiveTransactionIDsForReport(['B3'], 'B'); - // Then the expense is back in the carousel, so the arrows page through it again - expect((await readCarouselFromOnyx()).ids).toEqual(['B1', 'B2', 'B3']); + // Then the carousel is cleared, so the arrows don't come back for the last expense + expect((await readCarouselFromOnyx()).ids).toBeUndefined(); }); it('leaves a search carousel alone, since it spans several reports', async () => { @@ -332,11 +328,11 @@ describe('TransactionThreadNavigation carousel ownership', () => { await setActiveTransactionIDs(SPEND_PAGE_IDS, {source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); // When an expense is removed from report B - const onyxData = getRemoveActiveTransactionIDsForReportOnyxData(['B1'], 'B'); + await removeActiveTransactionIDsForReport(['B1'], 'B'); // Then the search carousel is untouched, because the expense is still one of its results - expect(onyxData).toBeUndefined(); expect(getActiveTransactionIDs()).toEqual({ids: SPEND_PAGE_IDS, descriptors: null, source: SEARCH_SOURCE, snapshotHash: SEARCH_HASH}); + expect((await readCarouselFromOnyx()).ids).toEqual(SPEND_PAGE_IDS); }); it("leaves another report's carousel alone", async () => { @@ -344,11 +340,11 @@ describe('TransactionThreadNavigation carousel ownership', () => { await setActiveTransactionIDs(REPORT_B_IDS, {source: REPORT_SOURCE}); // When an expense leaves a different report - const onyxData = getRemoveActiveTransactionIDsForReportOnyxData(['B1'], 'A'); + await removeActiveTransactionIDsForReport(['B1'], 'A'); // Then report B's carousel is untouched - expect(onyxData).toBeUndefined(); expect(getActiveTransactionIDs()).toEqual({ids: REPORT_B_IDS, descriptors: null, source: REPORT_SOURCE, snapshotHash: null}); + expect((await readCarouselFromOnyx()).ids).toEqual(REPORT_B_IDS); }); }); }); From 26f15668fd0187325651f25418b14a59ccaf88fc Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Wed, 14 Oct 2026 16:25:19 +0000 Subject: [PATCH 683/781] Clear the onboarding work email error when pressing Back to the intent list Co-authored-by: Abdelrahman Khattab --- .../BaseOnboardingWorkEmail.tsx | 6 ++- tests/ui/WorkEmailOnboardingNavigation.tsx | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 15b314007c43..329d746e509c 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -362,7 +362,11 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW {/* This screen normally opens onboarding, so there is nothing to go back to unless the intent list sent us here. */} Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute())} + onBackButtonPress={() => { + // Clear any work email error so the purpose screen doesn't render it without its translation params. + clearOnboardingMergeAccountBlocked(); + Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute()); + }} shouldShowCloseButton={isConciergeTaskFlow} onCloseButtonPress={onboardingValues?.isMergingAccountBlocked ? handleConciergeTaskErrorConfirm : returnToOriginReport} /> diff --git a/tests/ui/WorkEmailOnboardingNavigation.tsx b/tests/ui/WorkEmailOnboardingNavigation.tsx index 3c777bdcd92b..f246d87a36ad 100644 --- a/tests/ui/WorkEmailOnboardingNavigation.tsx +++ b/tests/ui/WorkEmailOnboardingNavigation.tsx @@ -367,6 +367,60 @@ describe('Onboarding work email navigation', () => { }); }); + it('should clear the work email error when Back is pressed so the intent list does not show it', async () => { + await TestHelper.signInWithTestUser(); + + // Given AddWorkEmail failed with the domain-controlled error while joining a company workspace + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + isMergingAccountBlocked: true, + }); + await Onyx.merge(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.mergeBlockScreen.domainControlledSubtitle'); + await Onyx.merge(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, { + onboardingWorkEmail: workEmail, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + }); + + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL], OnboardingModalNavigatorWithPurpose); + + await waitForBatchedUpdatesWithAct(); + + // When the user presses Back instead of confirming the blocked screen + await waitFor(() => { + expect(screen.getByLabelText(TestHelper.translateLocal('common.back'))).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('common.back'))); + + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE]); + }); + + // Then the error is cleared, because the intent list translates it without the work email and would show + // "undefined is a domain controlled login" + let errorMessageTranslationKey: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, + callback: (value) => { + errorMessageTranslationKey = value ?? undefined; + }, + }); + expect(errorMessageTranslationKey).toBeFalsy(); + + let onboardingValues: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (value) => { + onboardingValues = value; + }, + }); + expect(onboardingValues?.isMergingAccountBlocked).toBe(false); + }); + it('should keep Back working on a later visit to Join a workspace after the merge', async () => { await TestHelper.signInWithTestUser(); From f63475a0a324029cbd9f8bc90efff994c0d69c42 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:38:15 +0200 Subject: [PATCH 684/781] Size the bar hit area from the predicted layout --- src/components/Charts/BarChart/VerticalBarChartContent.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/Charts/BarChart/VerticalBarChartContent.tsx b/src/components/Charts/BarChart/VerticalBarChartContent.tsx index bfaac47883d5..73ecb16dac75 100644 --- a/src/components/Charts/BarChart/VerticalBarChartContent.tsx +++ b/src/components/Charts/BarChart/VerticalBarChartContent.tsx @@ -102,9 +102,6 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l }); const handleChartBoundsChange = (bounds: ChartBounds) => { - const domainWidth = bounds.right - bounds.left; - const {barWidth, gap} = getBarLayout(domainWidth, data.length); - barHitHalfWidth.set(barWidth > 0 ? barWidth / 2 + gap * BAR_HIT_GAP_RATIO : 0); plotTop.set(bounds.top); plotBottom.set(bounds.bottom); yZero.set(0); @@ -132,6 +129,7 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l }); const handleScaleChange = (xScale: Scale, yScale: Scale) => { + barHitHalfWidth.set(barLayout.barWidth > 0 ? barLayout.barWidth / 2 + barLayout.gap * BAR_HIT_GAP_RATIO : 0); yZero.set(yScale(0)); updateTickPositions(xScale, data.length); setPointPositions( From d9dd5d00316d9d7789d4f6bf5dede33330ca1363 Mon Sep 17 00:00:00 2001 From: "Joseph Kaufman (via MelvinBot)" Date: Wed, 14 Oct 2026 16:46:20 +0000 Subject: [PATCH 685/781] Update help docs for pending Plaid card transactions Co-authored-by: Joseph Kaufman --- .../Reconcile-Company-Card-Expenses.md | 2 +- .../reports/Report-Approvals.md | 4 ++-- .../Assign-Company-Cards.md | 1 + .../Connect-Personal-Cards.md | 2 ++ ...-up-a-Direct-Connection-for-Company-Cards.md | 17 ++++++++++++++++- ...Company-Card-Connection-Across-Workspaces.md | 1 + .../reports-and-expenses/Approve-Expenses.md | 2 +- .../Create-and-Submit-Reports.md | 4 ++-- 8 files changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md index 19b1ed1d8b83..c3aa80b9b295 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md +++ b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md @@ -94,5 +94,5 @@ Only Domain Admins can access the Reconciliation tool. - Amount - Last four digits of the card number -**Note: Only posted transactions will be imported.** +**Note: Only posted transactions will be imported, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post.** diff --git a/docs/articles/expensify-classic/reports/Report-Approvals.md b/docs/articles/expensify-classic/reports/Report-Approvals.md index 1af13e89bf83..e6d41e32b1ab 100644 --- a/docs/articles/expensify-classic/reports/Report-Approvals.md +++ b/docs/articles/expensify-classic/reports/Report-Approvals.md @@ -263,7 +263,7 @@ Only **Domain Admins** have access. - Amount - The last four digits of the card -**Note:** Only posted credit card transactions will import. +**Note:** Only posted credit card transactions will import, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post. No. Concierge Receipt Audit is automatically enabled for all Control plan workspaces and cannot be turned off. @@ -282,7 +282,7 @@ Use the **Reconciliation tool** to find missing expenses: - Amount - Last four digits of the card number -**Note:** Only **posted transactions** will be imported. +**Note:** Only **posted transactions** will be imported, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post. ## Can admins delete expenses on behalf of members? diff --git a/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md b/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md index 29403cce3bac..aa5d5e5eff58 100644 --- a/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md @@ -41,6 +41,7 @@ If you haven’t set up a company card feed yet, learn how to set one up: ## What happens after you assign company cards - Posted transactions from the assigned card import automatically into the member's account as expenses. +- If the card is connected through Plaid, pending transactions also import and are marked as pending until they post. [Learn which connections import pending transactions](/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards#do-pending-transactions-import-from-a-direct-connection). - Expensify imports transactions from the **Transaction start date** onward. - The imported company card expenses are added to reports and submitted according to workspace rules. diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md index 6ba11d39b978..024f46fb59b8 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md @@ -30,6 +30,8 @@ Anyone with an Expensify account can connect up to two personal cards for free. Once your card is connected, it will appear under **Cards** in the **Wallet**, and transactions will begin importing automatically. +If your card is connected through Plaid, pending transactions also import. They're marked as pending and update automatically when they post. Cards connected through your bank's secure login (oAuth) import transactions after they post. + --- ## What to do if you see a company card warning diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md index fe63c007544a..e3002e7f5c76 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md @@ -1,7 +1,7 @@ --- title: Set up a Direct Connection for Company Cards description: Learn how to connect a company card account using a direct connection so its cards can be assigned to workspace members and their transactions imported into Expensify. -keywords: [New Expensify, set up direct company card connection, connect company cards Expensify, direct connection Expensify, direct feed Expensify, Plaid company cards] +keywords: [New Expensify, set up direct company card connection, connect company cards Expensify, direct connection Expensify, direct feed Expensify, Plaid company cards, pending company card transactions, Plaid pending transactions] internalScope: Audience is workspace admins and card admins. Covers connecting a company card account using a direct connection or Plaid. Does not cover assigning cards, managing cards, or troubleshooting connections. retrievalIntent: How to set up a direct connection to a bank or card provider for company cards. contentType: task @@ -48,6 +48,7 @@ To set up a direct connection, you must: - Cards with recent expenses appear as a list and can be assigned to workspace members. - After a card is assigned, posted transactions import into the assigned member's account automatically as expenses. +- If the card is connected through Plaid, pending transactions also import. They're marked as pending until they post. [Learn which connections import pending transactions](#do-pending-transactions-import-from-a-direct-connection). [Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards). @@ -92,6 +93,20 @@ If you're still unable to connect to your bank, you can [import company card tra Yes, direct connections can be shared across workspaces. [Learn how to share a company card connection across workspaces](/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces). +## Do pending transactions import from a direct connection? + +Pending transactions import only for cards connected through Plaid (the **Other** option in the bank list). Cards connected to any other bank in the list import transactions after they post, which typically takes 1–3 business days. + +When a pending transaction imports: + +- It's marked as pending until it posts. +- It updates automatically when the transaction posts. +- It's removed if the bank cancels the transaction or it doesn't post. + +A report can't be submitted or approved while every expense on it is pending. + +If you delete a pending expense, the transaction may import again after it posts. + ## Can I import debit card transactions? Yes, debit card transactions can be imported by following the same steps for setting up a direct connection for company cards. diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md index 8b429d92725c..d86370751e60 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md @@ -52,6 +52,7 @@ To share a company card connection, you must: - Individual cards appear as a list and can be assigned to workspace members. - After a card is assigned, posted transactions import into the assigned member's account automatically as expenses. +- If the card is connected through Plaid, pending transactions also import and are marked as pending until they post. [Learn which connections import pending transactions](/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards#do-pending-transactions-import-from-a-direct-connection). [Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards). diff --git a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md index eb0f3ddc1f7d..6af9bfc99e0d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md @@ -168,7 +168,7 @@ The outdated row is removed from the report. The expense itself is not deleted # FAQ ## Why can't I action a pending expense? -Expensify Card expenses show as pending until the merchant posts them. This can take 1–3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). Only posted expenses can be approved. +Expensify Card expenses, and expenses from cards connected through Plaid, show as pending until the merchant posts them. This can take 1–3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). Only posted expenses can be approved. ## Why can’t I see Bypass approvers? Only Workspace admins can bypass the prescribed approval workflow. If **Prevent Self-Approval** is enabled, an admin cannot bypass approvals to approve their own report. diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md index cc0b8a686ee0..2f2f4bbef412 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md @@ -229,9 +229,9 @@ Unsubmitted reports will remain in your Open reports section. You can go back an The **Submit** button only appears once your report includes at least one valid expense. If the report has already been submitted, the button will no longer be visible. If your workspace has no approval workflow, the button will say **Mark as done** instead of **Submit**. -## Why can’t I submit a report with pending Expensify Card transactions? +## Why can’t I submit a report with pending card transactions? -If all transactions on your report are pending Expensify Card transactions, the report can't be submitted yet. The **Submit** button won't appear, and the report isn't counted in your **Submit** to-do. Pending transactions may take a few days to post. After at least one transaction has posted, the **Submit** button appears again and you can submit the report. +If all transactions on your report are pending card transactions, the report can't be submitted yet. This applies to the Expensify Card and to company or personal cards connected through Plaid. The **Submit** button won't appear, and the report isn't counted in your **Submit** to-do. Pending transactions may take a few days to post. After at least one transaction has posted, the **Submit** button appears again and you can submit the report. ## What happens to held expenses when I submit a report? From 6eb4e9a27813b52b191fa4af9614cd37f5f783ff Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 16:50:44 +0000 Subject: [PATCH 686/781] Auto-tighten eslint-seatbelt baseline --- config/eslint/eslint.seatbelt.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 968073a518b3..1e06d3ddeb34 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -848,7 +848,7 @@ "../../src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteAdaptedState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteQueryParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/getAdaptedStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../src/libs/Navigation/helpers/getAdaptedStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/Navigation/helpers/getOnboardingRouteFromScreen.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/helpers/getParamsFromRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Navigation/helpers/getPathFromState.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 @@ -914,7 +914,7 @@ "../../src/libs/ReportTitleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getPolicy" 27 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 16 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 8 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 7 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 31 "../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 13 "../../src/libs/sanitizeLogParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 From ddb90450fdc07cd154608b2da84d41a91d33f230 Mon Sep 17 00:00:00 2001 From: "Joseph Kaufman (via MelvinBot)" Date: Wed, 14 Oct 2026 17:13:57 +0000 Subject: [PATCH 687/781] Clarify that Submit still shows for pending-only reports and opens an error modal Co-authored-by: Joseph Kaufman --- .../reports-and-expenses/Create-and-Submit-Reports.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md index 2f2f4bbef412..305e9d198803 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md @@ -231,7 +231,7 @@ The **Submit** button only appears once your report includes at least one valid ## Why can’t I submit a report with pending card transactions? -If all transactions on your report are pending card transactions, the report can't be submitted yet. This applies to the Expensify Card and to company or personal cards connected through Plaid. The **Submit** button won't appear, and the report isn't counted in your **Submit** to-do. Pending transactions may take a few days to post. After at least one transaction has posted, the **Submit** button appears again and you can submit the report. +If all transactions on your report are pending card transactions, the report can't be submitted yet. This applies to the Expensify Card and to company or personal cards connected through Plaid. The **Submit** button still appears, but if you select it, a message explains that the report can't be submitted because all transactions are pending. The report also isn't counted in your **Submit** to-do. Pending transactions may take a few days to post. After at least one transaction has posted, you can submit the report. ## What happens to held expenses when I submit a report? From d1b739a6c187d36b71a40fa1ef334351e250ce5a Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Wed, 14 Oct 2026 17:14:19 +0000 Subject: [PATCH 688/781] Pass the work email when the purpose page translates merge-block errors Co-authored-by: Abdelrahman Khattab --- .../BaseOnboardingPurpose.tsx | 16 +++++++++++++- tests/ui/OnboardingPurpose.tsx | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 40858a9131c5..bd66c16d645a 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -32,6 +32,7 @@ import {clearOnboardingMergeAccountBlocked, setOnboardingErrorMessage, setOnboar import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/OnboardingWorkEmailForm'; import type {OnboardingPurpose} from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -86,6 +87,8 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID); const [adminsChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${onboardingAdminsChatReportID}`); const [personalDetailsForm] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM); + const [workEmailForm] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM); + const workEmail = workEmailForm?.[INPUT_IDS.ONBOARDING_WORK_EMAIL]; const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -220,6 +223,17 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const onboardingLocalRef = useRef(null); useImperativeHandle(isFocused ? OnboardingRefManager.ref : onboardingLocalRef, () => ({handleOuterClick}), [handleOuterClick]); + const getErrorMessage = () => { + if (!onboardingErrorMessage) { + return undefined; + } + // These merge-block messages interpolate the work email, so translate them with the email explicitly. + if (onboardingErrorMessage === 'onboarding.mergeBlockScreen.domainControlledSubtitle' || onboardingErrorMessage === 'onboarding.mergeBlockScreen.validatedPublicDomainSubtitle') { + return translate(onboardingErrorMessage, workEmail); + } + return translate(onboardingErrorMessage); + }; + if (isLoadingOnyxValue(onboardingErrorMessageResult)) { return null; } @@ -250,7 +264,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro - + ); diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index 80b19514c7aa..ceda8a077186 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -226,6 +226,27 @@ describe('OnboardingPurpose Page', () => { await waitForBatchedUpdatesWithAct(); }); + it('should show the work email in a domain-controlled error that reaches the purpose page', async () => { + // Given a domain-controlled merge error left behind by the work email step, with the email still in the form + const workEmail = 'tester@applause.expensifail.com'; + await TestHelper.signInWithTestUser(1, 'test@gmail.com'); + await act(async () => { + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, {onboardingWorkEmail: workEmail}); + await Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.mergeBlockScreen.domainControlledSubtitle'); + }); + + // When the purpose page renders that error + const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); + await waitForBatchedUpdatesWithAct(); + + // Then the message names the work email instead of "undefined", because the key interpolates the email + expect(screen.getByText(TestHelper.translateLocal('onboarding.mergeBlockScreen.domainControlledSubtitle', workEmail))).toBeOnTheScreen(); + expect(screen.queryByText(/undefined is a domain controlled login/)).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should navigate to personal details page when user selects EMPLOYER and is from public domain', async () => { await TestHelper.signInWithTestUser(); From b9d701d81a4e62404755fd63553bd95c6affd178 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 10:29:31 -0700 Subject: [PATCH 689/781] fix(report): use shared onyx data type --- src/libs/actions/Report/index.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 32ec8c6d3db9..9494c9f9fb09 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -224,6 +224,7 @@ import CONFIG from '@src/CONFIG'; import type {OnboardingAccounting} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxData} from '@src/types/onyx/Request'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/NewRoomForm'; @@ -4897,10 +4898,7 @@ function isNoSupportRepAvailableResponse(response: {jsonCode?: number | string; function openSupportTicket(resolvedSupportTicketReportID?: string) { const newSupportTicketReportID = resolvedSupportTicketReportID ? undefined : generateReportID(); const parameters: CreateSupportTicketParams = resolvedSupportTicketReportID ? {resolvedSupportTicketReportID, idempotencyKey: Str.guid()} : {newSupportTicketReportID}; - const onyxData: { - optimisticData?: Array>; - failureData?: Array>; - } = newSupportTicketReportID + const onyxData: OnyxData = newSupportTicketReportID ? { optimisticData: [ { From f68d4beb3a35c9e1945881c71d8b0130d9a66f0d Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 10:32:41 -0700 Subject: [PATCH 690/781] fix(support): stack survey feedback controls --- .../ReportActionItem/SupportTicketSurvey.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/components/ReportActionItem/SupportTicketSurvey.tsx b/src/components/ReportActionItem/SupportTicketSurvey.tsx index 4af75ac2c326..8ffdd21d53a9 100644 --- a/src/components/ReportActionItem/SupportTicketSurvey.tsx +++ b/src/components/ReportActionItem/SupportTicketSurvey.tsx @@ -59,19 +59,15 @@ function SupportTicketSurvey({action, report, reportID}: SupportTicketSurveyProp } return ( - - {translate('supportTicket.surveyPrompt')} + + {translate('supportTicket.surveyPrompt')} {canRateSurvey && ( - - - + )} ); From 98eaa391d532734f84e1dcc983fc97587a4703d0 Mon Sep 17 00:00:00 2001 From: "Joseph Kaufman (via MelvinBot)" Date: Wed, 14 Oct 2026 17:33:50 +0000 Subject: [PATCH 691/781] State the pending-transaction submission rule without describing button visibility Co-authored-by: Joseph Kaufman --- .../reports-and-expenses/Create-and-Submit-Reports.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md index 305e9d198803..d7d4cb2a50f2 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md @@ -231,7 +231,7 @@ The **Submit** button only appears once your report includes at least one valid ## Why can’t I submit a report with pending card transactions? -If all transactions on your report are pending card transactions, the report can't be submitted yet. This applies to the Expensify Card and to company or personal cards connected through Plaid. The **Submit** button still appears, but if you select it, a message explains that the report can't be submitted because all transactions are pending. The report also isn't counted in your **Submit** to-do. Pending transactions may take a few days to post. After at least one transaction has posted, you can submit the report. +If all transactions on a report are pending, the report can’t be submitted yet. This applies to Expensify Card transactions and to company or personal cards connected through Plaid. You can submit the report once at least one transaction posts. ## What happens to held expenses when I submit a report? From 36ec9f3f74673d03379e3e0d431eaac675c1ff30 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 10:35:31 -0700 Subject: [PATCH 692/781] style(report): sort onyx data import --- src/libs/actions/Report/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 9494c9f9fb09..8ab475c25cea 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -224,7 +224,6 @@ import CONFIG from '@src/CONFIG'; import type {OnboardingAccounting} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OnyxData} from '@src/types/onyx/Request'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/NewRoomForm'; @@ -264,6 +263,7 @@ import type {CurrentUserPersonalDetails, Timezone} from '@src/types/onyx/Persona import type {ConnectionName} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participants, Participant as ReportParticipant, RoomVisibility, WriteCapability} from '@src/types/onyx/Report'; import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; +import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; From 178dc4e21df5e39f4c505f7beb2ff3b8f6678bda Mon Sep 17 00:00:00 2001 From: "Joseph Kaufman (via MelvinBot)" Date: Wed, 14 Oct 2026 17:41:22 +0000 Subject: [PATCH 693/781] Note that pending Plaid card transactions can auto-merge with SmartScanned receipts Co-authored-by: Joseph Kaufman --- .../reports-and-expenses/Why-Expenses-Duplicate.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md b/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md index 5dd2c097d3c4..c14c4fb2ee4d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md +++ b/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md @@ -35,14 +35,14 @@ Expensify automatically merges a SmartScanned receipt with a matching card trans - The transaction dates match - The currencies match or fall within the allowed foreign exchange tolerance - Only one possible match exists -- The card transaction posts within seven days of the receipt being SmartScanned +- The card transaction posts within seven days of the receipt being SmartScanned. For cards connected through Plaid, the pending transaction can merge before it posts. **Expenses will not automatically merge if:** - The receipt was not SmartScanned or SmartScan did not complete -- Expense details were edited before the card transaction posted +- Expense details were edited before the card transaction posted (or, for cards connected through Plaid, before the pending transaction imported) - The amount, date, or currency differs beyond tolerance -- The card transaction posts after seven days +- The card transaction posts after seven days (or, for cards connected through Plaid, the pending transaction imports after seven days) - The foreign exchange difference exceeds five percent - The receipt is already matched to another expense - Both expenses are card transactions @@ -94,7 +94,7 @@ If both expenses are card transactions, they cannot be merged. Instead, [delete This happens when the receipt and card transaction did not merge into a single expense. This is usually due to differences in amount, date, currency, or timing. ## Why didn’t my receipt merge with my card transaction? -SmartScan requires a close match in amount, date, and currency. If those details differ, or if the card transaction has not posted within seven days, the expenses will not merge automatically. +SmartScan requires a close match in amount, date, and currency. If those details differ, or if the card transaction has not posted within seven days (for cards connected through Plaid, imported as pending within seven days), the expenses will not merge automatically. ## Can uploading the same receipt twice create duplicates? From 1bcda5d9c29be46f59531d20ab2e45acc4fd09c5 Mon Sep 17 00:00:00 2001 From: "Carlos Miceli (via MelvinBot)" Date: Wed, 14 Oct 2026 17:44:51 +0000 Subject: [PATCH 694/781] Let Android back leave onboarding work email when the intent list opened it Co-authored-by: Carlos Miceli --- .../BaseOnboardingWorkEmail.tsx | 6 +- .../OnboardingWorkEmail/index.native.tsx | 9 ++- tests/ui/WorkEmailOnboardingNativeBack.tsx | 77 ++++++++++++++++++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 15b314007c43..2a8522c5c7c1 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -362,7 +362,11 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW {/* This screen normally opens onboarding, so there is nothing to go back to unless the intent list sent us here. */} Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute())} + onBackButtonPress={() => { + // Clear any work email error so it doesn't follow the user back to the intent list. + clearOnboardingMergeAccountBlocked(); + Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute()); + }} shouldShowCloseButton={isConciergeTaskFlow} onCloseButtonPress={onboardingValues?.isMergingAccountBlocked ? handleConciergeTaskErrorConfirm : returnToOriginReport} /> diff --git a/src/pages/OnboardingWorkEmail/index.native.tsx b/src/pages/OnboardingWorkEmail/index.native.tsx index b43a629125a2..b64770644601 100644 --- a/src/pages/OnboardingWorkEmail/index.native.tsx +++ b/src/pages/OnboardingWorkEmail/index.native.tsx @@ -9,9 +9,16 @@ import type {OnboardingWorkEmailProps} from './types'; import BaseOnboardingWorkEmail from './BaseOnboardingWorkEmail'; function OnboardingWorkEmail(props: OnboardingWorkEmailProps) { + const {index: routeIndex} = props.navigation.getState(); + // To block android native back button behavior useFocusEffect( useCallback(() => { + // We don't want to block the back button if this is not the first route, e.g. when the intent list sent us here + if (routeIndex !== 0) { + return; + } + // Return true to indicate that the back button press is handled here const backAction = () => { setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); @@ -21,7 +28,7 @@ function OnboardingWorkEmail(props: OnboardingWorkEmailProps) { const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); return () => backHandler.remove(); - }, []), + }, [routeIndex]), ); return ( { expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PERSONAL_DETAILS]); }); }); + + it('should swallow a system back press and show the error when work email is the first onboarding screen', async () => { + // Given work email is the only onboarding route, which is where a public-domain account starts onboarding + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false}); + }); + + renderOnboardingStack([SCREENS.ONBOARDING.WORK_EMAIL]); + + await waitForBatchedUpdatesWithAct(); + + // When Android's system back button is pressed + const consumed = pressHardwareBack(); + + await waitForBatchedUpdatesWithAct(); + + // Then the press is consumed so it cannot pop the onboarding modal, and the user is told to finish setup + expect(consumed).toBe(true); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.WORK_EMAIL]); + let errorMessage: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, + callback: (value) => { + errorMessage = value ?? undefined; + }, + }); + expect(errorMessage).toBe('onboarding.purpose.errorBackButton'); + }); + + it('should let a system back press through when the join workspace intent opened work email', async () => { + // Given the intent list sent the user to work email after they picked "Join my company workspace", so there + // is a real screen behind this one + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + }); + + renderOnboardingStack([SCREENS.ONBOARDING.PERSONAL_DETAILS]); + + await waitForBatchedUpdatesWithAct(); + + // Push work email after mount like the intent list does, so its back handler subscribes after the + // NavigationContainer's one and gets the press first, as it does in the app + act(() => { + navigationRef.dispatch(StackActions.push(SCREENS.ONBOARDING.WORK_EMAIL)); + }); + + await waitForBatchedUpdatesWithAct(); + + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PERSONAL_DETAILS, SCREENS.ONBOARDING.WORK_EMAIL]); + + // When Android's system back button is pressed + pressHardwareBack(); + + await waitForBatchedUpdatesWithAct(); + + // Then no handler swallows it, the onboarding stack pops as normal, and no error is left behind + await waitFor(() => { + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PERSONAL_DETAILS]); + }); + let errorMessage: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, + callback: (value) => { + errorMessage = value ?? undefined; + }, + }); + expect(errorMessage).toBeUndefined(); + }); }); From a7e3407635d268370ac2558e67430dd584953d99 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 10:54:26 -0700 Subject: [PATCH 695/781] fix(support): show selected survey reaction bubble --- .../ReportActionItem/SupportTicketSurvey.tsx | 1 + .../ConciergeFeedbackPrompt.tsx | 49 +++++++++++++------ 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/components/ReportActionItem/SupportTicketSurvey.tsx b/src/components/ReportActionItem/SupportTicketSurvey.tsx index 8ffdd21d53a9..58053c77a38b 100644 --- a/src/components/ReportActionItem/SupportTicketSurvey.tsx +++ b/src/components/ReportActionItem/SupportTicketSurvey.tsx @@ -67,6 +67,7 @@ function SupportTicketSurvey({action, report, reportID}: SupportTicketSurveyProp reportID={reportID} shouldShowPrompt={false} shouldPersistAfterRating + shouldRenderSelectedReactionBubble /> )} diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index ffa5b4e2724a..c95b7e7bd56f 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -1,6 +1,7 @@ import type {Emoji} from '@assets/emojis/types'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import EmojiReactionBubble from '@components/Reactions/EmojiReactionBubble'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; @@ -47,6 +48,9 @@ type ConciergeFeedbackPromptProps = { /** Whether to place only the thumbs inline with surrounding content */ shouldRenderInline?: boolean; + + /** Whether a selected thumb should use the standard selected reaction bubble */ + shouldRenderSelectedReactionBubble?: boolean; }; /** A reaction can be stored under the emoji name or under its hexcode, so both keys are read */ @@ -90,28 +94,39 @@ type ConciergeFeedbackThumbProps = { /** Whether the current user selected this rating */ isSelected: boolean; + + /** Whether a selected thumb should use the standard selected reaction bubble */ + shouldRenderSelectedReactionBubble: boolean; }; -function ConciergeFeedbackThumb({emoji, label, accessibilityLabel, onPress, shouldShowTooltip, isSelected}: ConciergeFeedbackThumbProps) { +function ConciergeFeedbackThumb({emoji, label, accessibilityLabel, onPress, shouldShowTooltip, isSelected, shouldRenderSelectedReactionBubble}: ConciergeFeedbackThumbProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); - const thumb = ( - - {emoji.code} - - ); + const thumb = + isSelected && shouldRenderSelectedReactionBubble ? ( + + ) : ( + + {emoji.code} + + ); return shouldShowTooltip ? {thumb} : thumb; } @@ -123,6 +138,7 @@ function ConciergeFeedbackPrompt({ shouldPersistAfterRating = false, shouldShowTooltips = true, shouldRenderInline = false, + shouldRenderSelectedReactionBubble = false, }: ConciergeFeedbackPromptProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -166,6 +182,7 @@ function ConciergeFeedbackPrompt({ onPress={callFunctionIfActionIsAllowed(() => rate(emoji))} shouldShowTooltip={shouldShowTooltips} isSelected={isSelected} + shouldRenderSelectedReactionBubble={shouldRenderSelectedReactionBubble} /> ); From 0fec3e22642df34637ed5cdfdfcaffdafad845cb Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 10:56:05 -0700 Subject: [PATCH 696/781] fix(support): hide unselected survey reaction --- .../actionContents/ConciergeFeedbackPrompt.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index c95b7e7bd56f..b827756dc71c 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -51,6 +51,9 @@ type ConciergeFeedbackPromptProps = { /** Whether a selected thumb should use the standard selected reaction bubble */ shouldRenderSelectedReactionBubble?: boolean; + + /** Whether to hide the unselected thumb after the user rates the action */ + shouldHideUnselectedThumbAfterRating?: boolean; }; /** A reaction can be stored under the emoji name or under its hexcode, so both keys are read */ @@ -139,6 +142,7 @@ function ConciergeFeedbackPrompt({ shouldShowTooltips = true, shouldRenderInline = false, shouldRenderSelectedReactionBubble = false, + shouldHideUnselectedThumbAfterRating = false, }: ConciergeFeedbackPromptProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -172,7 +176,10 @@ function ConciergeFeedbackPrompt({ toggleEmojiReaction(reportID, action, emoji, reactions, preferredSkinTone, currentUserAccountID, reportActions, true, true, emoji.name === thumbsUp.name ? thumbsDown : thumbsUp); }; - const hasRated = hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID) || hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID); + const hasReactedWithThumbsUp = hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID); + const hasReactedWithThumbsDown = hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID); + const hasRated = hasReactedWithThumbsUp || hasReactedWithThumbsDown; + const shouldHideUnselectedThumbs = shouldHideUnselectedThumbAfterRating && hasRated; const getFeedbackThumb = (emoji: Emoji, isSelected: boolean) => ( - {getFeedbackThumb(thumbsUp, hasReactedWithEmoji(thumbsUp, reactions, currentUserAccountID))} - {getFeedbackThumb(thumbsDown, hasReactedWithEmoji(thumbsDown, reactions, currentUserAccountID))} + {(!shouldHideUnselectedThumbs || hasReactedWithThumbsUp) && getFeedbackThumb(thumbsUp, hasReactedWithThumbsUp)} + {(!shouldHideUnselectedThumbs || hasReactedWithThumbsDown) && getFeedbackThumb(thumbsDown, hasReactedWithThumbsDown)} ); From 3554a90d281d216a150598073416e1736e5fe3d1 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 10:56:30 -0700 Subject: [PATCH 697/781] fix(support): retain only selected survey feedback --- src/components/ReportActionItem/SupportTicketSurvey.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ReportActionItem/SupportTicketSurvey.tsx b/src/components/ReportActionItem/SupportTicketSurvey.tsx index 58053c77a38b..c4bead1477c2 100644 --- a/src/components/ReportActionItem/SupportTicketSurvey.tsx +++ b/src/components/ReportActionItem/SupportTicketSurvey.tsx @@ -68,6 +68,7 @@ function SupportTicketSurvey({action, report, reportID}: SupportTicketSurveyProp shouldShowPrompt={false} shouldPersistAfterRating shouldRenderSelectedReactionBubble + shouldHideUnselectedThumbAfterRating /> )} From 05ac47502412515bc7ef518fffef091d7183a7d0 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 10:57:59 -0700 Subject: [PATCH 698/781] style(support): space selected survey reaction --- .../inbox/report/actionContents/ConciergeFeedbackPrompt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index b827756dc71c..cc8888ab63a6 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -211,7 +211,7 @@ function ConciergeFeedbackPrompt({ if (shouldPersistAfterRating && hasRated) { return ( - + {isDisplayedThankMessage && {translate('concierge.feedback.thanks')}} {feedbackThumbs} From 8e973d93150769b7000c14da4918cbfe5d26c000 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 11:01:42 -0700 Subject: [PATCH 699/781] fix(support): stack survey acknowledgement --- .../inbox/report/actionContents/ConciergeFeedbackPrompt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index cc8888ab63a6..5961b7fa8c74 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -211,7 +211,7 @@ function ConciergeFeedbackPrompt({ if (shouldPersistAfterRating && hasRated) { return ( - + {isDisplayedThankMessage && {translate('concierge.feedback.thanks')}} {feedbackThumbs} From 40b0917ab8435b61cc3ac645e98426096e48a7a6 Mon Sep 17 00:00:00 2001 From: Joseph Kaufman <54866469+joekaufmanexpensify@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:18:15 -0400 Subject: [PATCH 700/781] Improve clarity on connecting personal cards Clarified the connection process for bank accounts and updated wording for transaction imports. --- .../Connect-Personal-Cards.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md index 024f46fb59b8..17f47f5b2997 100644 --- a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md @@ -24,13 +24,13 @@ Anyone with an Expensify account can connect up to two personal cards for free. 2. Tap **Add personal card**. 3. Select your country from the country selector. 4. If you selected **United States**, choose your bank from the bank selector. - - If your bank is listed, you'll be prompted to connect via your bank's secure login (oAuth). + - If your bank is listed, you'll be prompted to directly connect via your bank's secure login. - If your bank is not listed, select **Other** to connect via Plaid. 5. Follow the prompts to log into your bank and authorize the connection. Once your card is connected, it will appear under **Cards** in the **Wallet**, and transactions will begin importing automatically. -If your card is connected through Plaid, pending transactions also import. They're marked as pending and update automatically when they post. Cards connected through your bank's secure login (oAuth) import transactions after they post. +If your card is connected through Plaid, pending transactions also import. They're marked as pending and update automatically when they post. Cards connected directly through your bank's secure login import transactions after they post. --- From 826d78c8fb446c124b90e082f8db29094ad9b598 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 11:23:24 -0700 Subject: [PATCH 701/781] fix(support): refine survey feedback layout --- .../ReportActionItem/SupportTicketSurvey.tsx | 8 +++---- src/languages/de.ts | 2 ++ src/languages/el.ts | 2 ++ src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 2 ++ src/languages/it.ts | 2 ++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 2 ++ src/languages/pl.ts | 2 ++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 2 ++ .../ConciergeFeedbackPrompt.tsx | 24 ++++++++++++++++--- 13 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/components/ReportActionItem/SupportTicketSurvey.tsx b/src/components/ReportActionItem/SupportTicketSurvey.tsx index c4bead1477c2..7f90b4ba03d2 100644 --- a/src/components/ReportActionItem/SupportTicketSurvey.tsx +++ b/src/components/ReportActionItem/SupportTicketSurvey.tsx @@ -1,5 +1,3 @@ -import Text from '@components/Text'; - import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -60,13 +58,15 @@ function SupportTicketSurvey({action, report, reportID}: SupportTicketSurveyProp return ( - {translate('supportTicket.surveyPrompt')} {canRateSurvey && ( diff --git a/src/languages/de.ts b/src/languages/de.ts index 5020db12fd10..eb3c86b2cc35 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -9993,6 +9993,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc fallbackTitle: 'Support-Ticket', resolved: 'Dieses Support-Ticket ist gelöst.', surveyPrompt: 'Wie war Ihre Erfahrung mit dem Support?', + great: 'Großartig', + poor: 'Schlecht', reopenTicket: 'Ticket erneut öffnen', }, statementPage: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 5b45dce6c4f6..7febd4d2a858 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10174,6 +10174,8 @@ ${reportName}`, fallbackTitle: 'Αίτημα υποστήριξης', resolved: 'Αυτό το αίτημα υποστήριξης επιλύθηκε.', surveyPrompt: 'Πώς ήταν η εμπειρία σας με την υποστήριξη;', + great: 'Εξαιρετική', + poor: 'Κακή', reopenTicket: 'Επανέναρξη αιτήματος', }, statementPage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index c165dc875607..58ea837b4e2a 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10190,6 +10190,8 @@ const translations = { fallbackTitle: 'Support ticket', resolved: 'This support ticket is resolved.', surveyPrompt: 'How was your support experience?', + great: 'Great', + poor: 'Poor', reopenTicket: 'Reopen ticket', }, statementPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index fcf7f9908696..e83e5a4760ad 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9812,6 +9812,8 @@ ${reportName}`, fallbackTitle: 'Ticket de soporte', resolved: 'Este ticket de soporte está resuelto.', surveyPrompt: '¿Cómo fue tu experiencia con el soporte?', + great: 'Excelente', + poor: 'Mala', reopenTicket: 'Reabrir ticket', }, statementPage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 628b06a67eea..2fdbe7534af8 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10019,6 +10019,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e fallbackTitle: 'Ticket d’assistance', resolved: 'Ce ticket d’assistance est résolu.', surveyPrompt: 'Comment s’est passée votre expérience avec l’assistance ?', + great: 'Excellente', + poor: 'Mauvaise', reopenTicket: 'Rouvrir le ticket', }, statementPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 22f706e49ffd..a38e3eedcc50 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -9946,6 +9946,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, fallbackTitle: 'Ticket di supporto', resolved: 'Questo ticket di assistenza è risolto.', surveyPrompt: 'Com’è stata la tua esperienza con l’assistenza?', + great: 'Ottima', + poor: 'Scarsa', reopenTicket: 'Riapri ticket', }, statementPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a865475011d6..44be39857247 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -9803,6 +9803,8 @@ ${reportName}`, fallbackTitle: 'サポートチケット', resolved: 'このサポートチケットは解決済みです。', surveyPrompt: 'サポートの対応はいかがでしたか?', + great: '素晴らしい', + poor: '悪い', reopenTicket: 'チケットを再開', }, statementPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a80f9d5709ab..3a03bcaf168e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -9912,6 +9912,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, fallbackTitle: 'Supportticket', resolved: 'Dit supportticket is opgelost.', surveyPrompt: 'Hoe was je ervaring met de ondersteuning?', + great: 'Geweldig', + poor: 'Slecht', reopenTicket: 'Ticket opnieuw openen', }, statementPage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a97262aa05bc..3f0daf88b8dd 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9923,6 +9923,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, fallbackTitle: 'Zgłoszenie do pomocy technicznej', resolved: 'To zgłoszenie do pomocy technicznej zostało rozwiązane.', surveyPrompt: 'Jak oceniasz swoje doświadczenie ze wsparciem?', + great: 'Świetne', + poor: 'Słabe', reopenTicket: 'Otwórz zgłoszenie ponownie', }, statementPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 31602ff2127e..c72215d0628d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -9910,6 +9910,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, fallbackTitle: 'Chamado de suporte', resolved: 'Este chamado de suporte foi resolvido.', surveyPrompt: 'Como foi sua experiência com o suporte?', + great: 'Ótima', + poor: 'Ruim', reopenTicket: 'Reabrir chamado', }, statementPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index cb0b7ca26c71..68e0e9a9a97a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9542,6 +9542,8 @@ ${reportName}`, fallbackTitle: '支持工单', resolved: '此支持工单已解决。', surveyPrompt: '您对支持服务的体验如何?', + great: '很好', + poor: '很差', reopenTicket: '重新打开工单', }, statementPage: { diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index 5961b7fa8c74..8091ecaedeee 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -40,12 +40,21 @@ type ConciergeFeedbackPromptProps = { /** Whether to show the generic feedback prompt alongside the thumbs */ shouldShowPrompt?: boolean; + /** Custom prompt text shown alongside the thumbs */ + promptText?: string; + /** Whether the thumbs should remain available after the user rates the action */ shouldPersistAfterRating?: boolean; /** Whether hovering a thumb should show its tooltip */ shouldShowTooltips?: boolean; + /** Tooltip text for the positive feedback option */ + positiveTooltipText?: string; + + /** Tooltip text for the negative feedback option */ + negativeTooltipText?: string; + /** Whether to place only the thumbs inline with surrounding content */ shouldRenderInline?: boolean; @@ -138,8 +147,11 @@ function ConciergeFeedbackPrompt({ action, reportID, shouldShowPrompt = true, + promptText, shouldPersistAfterRating = false, shouldShowTooltips = true, + positiveTooltipText, + negativeTooltipText, shouldRenderInline = false, shouldRenderSelectedReactionBubble = false, shouldHideUnselectedThumbAfterRating = false, @@ -184,7 +196,7 @@ function ConciergeFeedbackPrompt({ const getFeedbackThumb = (emoji: Emoji, isSelected: boolean) => ( rate(emoji))} shouldShowTooltip={shouldShowTooltips} @@ -212,6 +224,7 @@ function ConciergeFeedbackPrompt({ if (shouldPersistAfterRating && hasRated) { return ( + {shouldRenderInline && shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} {isDisplayedThankMessage && {translate('concierge.feedback.thanks')}} {feedbackThumbs} @@ -219,7 +232,12 @@ function ConciergeFeedbackPrompt({ } if (shouldRenderInline) { - return feedbackThumbs; + return ( + + {shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} + {feedbackThumbs} + + ); } return ( @@ -227,7 +245,7 @@ function ConciergeFeedbackPrompt({ layout="horizontal" style={styles.alignItemsCenter} > - {shouldShowPrompt && {translate('concierge.feedback.prompt')}} + {shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} {feedbackThumbs} ); From 5c550a267bc261678a7a109e5942698da14e9fda Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 11:36:16 -0700 Subject: [PATCH 702/781] style(support): compact survey feedback spacing --- .../inbox/report/actionContents/ConciergeFeedbackPrompt.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index 8091ecaedeee..020bc17f9ea7 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -223,7 +223,7 @@ function ConciergeFeedbackPrompt({ if (shouldPersistAfterRating && hasRated) { return ( - + {shouldRenderInline && shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} {isDisplayedThankMessage && {translate('concierge.feedback.thanks')}} {feedbackThumbs} @@ -233,7 +233,7 @@ function ConciergeFeedbackPrompt({ if (shouldRenderInline) { return ( - + {shouldShowPrompt && {promptText ?? translate('concierge.feedback.prompt')}} {feedbackThumbs} From aecf52f447afa3fdadf86737de1f503240de89ca Mon Sep 17 00:00:00 2001 From: "Carlos Miceli (via MelvinBot)" Date: Wed, 14 Oct 2026 18:56:04 +0000 Subject: [PATCH 703/781] Clear work email errors on Android back to the intent list Co-authored-by: Carlos Miceli --- src/pages/OnboardingWorkEmail/index.native.tsx | 16 +++++++++------- tests/ui/WorkEmailOnboardingNativeBack.tsx | 11 +++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/pages/OnboardingWorkEmail/index.native.tsx b/src/pages/OnboardingWorkEmail/index.native.tsx index b64770644601..693c128fc7f3 100644 --- a/src/pages/OnboardingWorkEmail/index.native.tsx +++ b/src/pages/OnboardingWorkEmail/index.native.tsx @@ -1,4 +1,4 @@ -import {setOnboardingErrorMessage} from '@userActions/Welcome'; +import {clearOnboardingMergeAccountBlocked, setOnboardingErrorMessage} from '@userActions/Welcome'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback} from 'react'; @@ -14,13 +14,15 @@ function OnboardingWorkEmail(props: OnboardingWorkEmailProps) { // To block android native back button behavior useFocusEffect( useCallback(() => { - // We don't want to block the back button if this is not the first route, e.g. when the intent list sent us here - if (routeIndex !== 0) { - return; - } - - // Return true to indicate that the back button press is handled here const backAction = () => { + // We don't want to block the back button if this is not the first route, e.g. when the intent list sent us here. + // Clear any work email error like the header back button does, and return false so navigation still pops the screen. + if (routeIndex !== 0) { + clearOnboardingMergeAccountBlocked(); + return false; + } + + // Return true to indicate that the back button press is handled here setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); return true; }; diff --git a/tests/ui/WorkEmailOnboardingNativeBack.tsx b/tests/ui/WorkEmailOnboardingNativeBack.tsx index 5c1e74529e9f..fbc58850f49e 100644 --- a/tests/ui/WorkEmailOnboardingNativeBack.tsx +++ b/tests/ui/WorkEmailOnboardingNativeBack.tsx @@ -425,6 +425,15 @@ describe('Onboarding work email validation (Android system back)', () => { expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PERSONAL_DETAILS, SCREENS.ONBOARDING.WORK_EMAIL]); + // Given adding the work email failed, leaving errors that the intent list would render if they followed the user back + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {isMergingAccountBlocked: true}); + await Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, 'onboarding.purpose.errorBackButton'); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM, {errors: {error: 'Work email error'}}); + }); + + await waitForBatchedUpdatesWithAct(); + // When Android's system back button is pressed pressHardwareBack(); @@ -442,5 +451,7 @@ describe('Onboarding work email validation (Android system back)', () => { }, }); expect(errorMessage).toBeUndefined(); + expect((await getOnboardingValues())?.isMergingAccountBlocked).toBe(false); + expect((await getOnyxValue(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM))?.errors).toBeFalsy(); }); }); From 803a690a77fb6e146154207bacee5c962b9ff449 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 12:00:16 -0700 Subject: [PATCH 704/781] fix(concierge): preserve feedback acknowledgement timing --- .../ConciergeFeedbackPrompt.tsx | 20 +++++++----- tests/ui/ReportActionItemTest.tsx | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx index 020bc17f9ea7..e879160ebb9b 100644 --- a/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx +++ b/src/pages/inbox/report/actionContents/ConciergeFeedbackPrompt.tsx @@ -172,16 +172,20 @@ function ConciergeFeedbackPrompt({ // Either rating starts the acknowledgement window, and the zero fallback keeps an unanswered survey undefined. const latestReactionTimestamp = Math.max(thumbsUpReactedAt ?? 0, thumbsDownReactedAt ?? 0) || undefined; - // Regular Concierge feedback keeps its existing thumbs-up-only acknowledgement, while surveys acknowledge either rating. - const acknowledgementTimestamp = shouldPersistAfterRating ? latestReactionTimestamp : thumbsUpReactedAt; - // The reaction that was already there when this row mounted is an old rating, so only a reaction that lands while the row is open is acknowledged - const [mountedWithAcknowledgementTimestamp] = useState(acknowledgementTimestamp); + // Regular Concierge feedback preserves its debounced thumbs-up acknowledgement. + const [mountedWithThumbsUpReactedAt] = useState(thumbsUpReactedAt); + const hasJustReactedWithThumbsUp = thumbsUpReactedAt !== undefined && thumbsUpReactedAt !== mountedWithThumbsUpReactedAt; + const hasSettledAfterThumbsUpReaction = useDebouncedValue(hasJustReactedWithThumbsUp, THANKS_VISIBLE_DURATION_MS); + const isRegularThankMessageDisplayed = hasJustReactedWithThumbsUp && !hasSettledAfterThumbsUpReaction; - // The debounced timestamp catches up once the window has passed, and a new reaction restarts that window. - const debouncedAcknowledgementTimestamp = useDebouncedValue(acknowledgementTimestamp, THANKS_VISIBLE_DURATION_MS); - const isDisplayedThankMessage = - acknowledgementTimestamp !== undefined && acknowledgementTimestamp !== mountedWithAcknowledgementTimestamp && acknowledgementTimestamp !== debouncedAcknowledgementTimestamp; + // A survey accepts either rating, so its acknowledgement restarts when the selected reaction changes. + const [mountedWithLatestReactionTimestamp] = useState(latestReactionTimestamp); + const debouncedLatestReactionTimestamp = useDebouncedValue(latestReactionTimestamp, THANKS_VISIBLE_DURATION_MS); + const isSurveyThankMessageDisplayed = + latestReactionTimestamp !== undefined && latestReactionTimestamp !== mountedWithLatestReactionTimestamp && latestReactionTimestamp !== debouncedLatestReactionTimestamp; + + const isDisplayedThankMessage = shouldPersistAfterRating ? isSurveyThankMessageDisplayed : isRegularThankMessageDisplayed; const rate = (emoji: Emoji) => { // Skin tone is ignored on compare so a user whose preferred tone changed toggles their existing reaction instead of adding a second one diff --git a/tests/ui/ReportActionItemTest.tsx b/tests/ui/ReportActionItemTest.tsx index 6212a44f8585..b55b26eb8891 100644 --- a/tests/ui/ReportActionItemTest.tsx +++ b/tests/ui/ReportActionItemTest.tsx @@ -3609,6 +3609,12 @@ describe('ReportActionItem', () => { }); } + async function clearReactions(action: ReportAction) { + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, {}); + }); + } + it('shows the acknowledgement in a second copy of the chat that did not take the press', async () => { // The side panel and the central pane each mount their own copy, and the reaction is what they share const action = createConciergeComment(); @@ -3626,6 +3632,32 @@ describe('ReportActionItem', () => { expect(screen.queryByText(prompt())).not.toBeOnTheScreen(); }); + it('does not show the acknowledgement again when thumbs-up is added shortly after being removed', async () => { + jest.useFakeTimers(); + + try { + const action = createConciergeComment(); + renderItemWithAction(action, true); + await waitForBatchedUpdatesWithAct(); + + await reactWithThumbsUp(action, '2025-07-12 09:03:18.000'); + expect(screen.getByText(translateLocal('concierge.feedback.thanks'))).toBeOnTheScreen(); + + await act(async () => { + jest.advanceTimersByTime(4000); + }); + expect(screen.queryByText(translateLocal('concierge.feedback.thanks'))).not.toBeOnTheScreen(); + + await clearReactions(action); + expect(screen.getByText(prompt())).toBeOnTheScreen(); + + await reactWithThumbsUp(action, '2025-07-12 09:03:19.000'); + expect(screen.queryByText(translateLocal('concierge.feedback.thanks'))).not.toBeOnTheScreen(); + } finally { + jest.useRealTimers(); + } + }); + it('does not acknowledge a rating that was already there when the chat was opened', async () => { const action = createConciergeComment(); await reactWithThumbsUp(action, DateUtils.getDBTime()); From 9ef3c3a699283cfcdfa3c3b4af17467a851c6298 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 15 Oct 2026 02:24:34 +0700 Subject: [PATCH 705/781] Show empty state instead of No results when default view hides all rows --- src/components/Table/TableBody.tsx | 7 +++- .../TableEmptyStates/TableNoResultsState.tsx | 5 ++- tests/ui/TableTest.tsx | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 9a4db2f7aead..faa96b4da20f 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -164,6 +164,7 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. noResultsStateElement, tableListMetadata, isEmptyResult, + isDefaultViewEmpty, scrollWidth, tableWidth, } = useTableContext(); @@ -337,8 +338,10 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. ); - const emptyStateContent = - tableListMetadata.hasPageHeader && isEmptyResult ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); + // When the default view hides every row (e.g. all workspaces are archived), no search or filter is active, + // so show the empty state rather than "No results". + const shouldShowNoResultsState = tableListMetadata.hasPageHeader && isEmptyResult && !isDefaultViewEmpty; + const emptyStateContent = shouldShowNoResultsState ? (noResultsStateElement ?? EmptyResultComponent) : (emptyStateElement ?? renderListComponent(ListEmptyComponent)); const footerElement = renderListComponent(ListFooterComponent); // Consumer footer flex growth is useful below normal rows, but inside the combined empty-state // footer it can expand over the page header. Preserve the remaining style while disabling growth. diff --git a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx index 60a93ca4a2c6..30c49a66fe35 100644 --- a/src/components/Table/TableEmptyStates/TableNoResultsState.tsx +++ b/src/components/Table/TableEmptyStates/TableNoResultsState.tsx @@ -21,10 +21,11 @@ type TableNoResultsStateProps = Omit { expect(screen.queryByTestId('row-2')).toBeNull(); expect(screen.getByTestId('generic-empty-state')).toBeTruthy(); }); + + it('renders the empty state instead of "No results" when a page-header table hides every row by default', () => { + // Given a page-header table (like the Workspaces list) whose default filter hides every row, with both + // a no-results state and an empty state declared + const props = createDefaultProps(); + const data: TestItem[] = [ + {keyForList: '1', id: '1', name: 'Archived A', category: 'archived', value: 1}, + {keyForList: '2', id: '2', name: 'Archived B', category: 'archived', value: 2}, + ]; + + // When the table renders with no search or filter selected + render( + + data={data} + columns={props.columns} + renderItem={props.renderItem} + keyExtractor={props.keyExtractor} + filters={statusFilterConfig} + isItemInFilter={isItemInStatusFilter} + > + + Page header + + + + + + , + ); + + // Then the empty state shows inside the list with the page header still visible, because the user did + // not search or filter, so "No results found" would be misleading + expect(screen.getByTestId('flash-list')).toBeTruthy(); + expect(screen.getByTestId('table-header-component')).toBeTruthy(); + expect(screen.getAllByTestId('generic-empty-state')).toHaveLength(1); + expect(screen.getByText('No workspaces')).toBeTruthy(); + expect(screen.queryByText('common.noResultsFound')).toBeNull(); + }); }); describe('immediate filter', () => { From db56fb9d149db23f025e709a4f221da0a518940a Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 12:34:04 -0700 Subject: [PATCH 706/781] test(report): cover support ticket cleanup --- tests/actions/SupportTicketTest.ts | 40 ++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/tests/actions/SupportTicketTest.ts b/tests/actions/SupportTicketTest.ts index 08544cd8adf2..1677429fa9a6 100644 --- a/tests/actions/SupportTicketTest.ts +++ b/tests/actions/SupportTicketTest.ts @@ -11,6 +11,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + jest.mock('@libs/API'); jest.mock('@libs/Navigation/helpers/getReportRouteForCurrentContext'); jest.mock('@libs/Navigation/Navigation'); @@ -26,7 +29,13 @@ const mockGenerateReportID = jest.mocked(generateReportID); const newSupportTicketReportID = 'optimisticSupportTicketReportID'; describe('actions/Report', () => { - beforeEach(() => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); mockMakeRequestWithSideEffects.mockResolvedValue({jsonCode: CONST.JSON_CODE.SUCCESS}); mockGetReportRouteForCurrentContext.mockReturnValue(`r/${newSupportTicketReportID}`); mockGenerateReportID.mockReturnValue(newSupportTicketReportID); @@ -80,9 +89,12 @@ describe('actions/Report', () => { }); it('removes the pending report when the request fails before receiving a response', async () => { - // Given a request that fails before the API can apply its failure data + // Given an optimistic support ticket in Onyx and a request that fails before the API can apply its failure data const networkError = new Error('Network request failed'); - const onyxUpdateSpy = jest.spyOn(Onyx, 'update').mockResolvedValue(undefined); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, { + reportID: newSupportTicketReportID, + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + }); mockMakeRequestWithSideEffects.mockRejectedValueOnce(networkError); // When the customer asks to talk to a human @@ -90,13 +102,21 @@ describe('actions/Report', () => { // Then the App removes the pending report and preserves the request failure await expect(request).rejects.toBe(networkError); - expect(onyxUpdateSpy).toHaveBeenCalledWith([ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, - value: null, - }, - ]); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + }); + + it('does not update Onyx when reopening a support ticket fails', async () => { + // Given a resolved ticket and a failed reassignment request + const resolvedSupportTicketReportID = 'resolvedSupportTicketReportID'; + const networkError = new Error('Network request failed'); + const onyxUpdateSpy = jest.spyOn(Onyx, 'update'); + mockMakeRequestWithSideEffects.mockRejectedValueOnce(networkError); + + // When the customer reopens the ticket + await expect(openSupportTicket(resolvedSupportTicketReportID)).rejects.toBe(networkError); + + // Then no optimistic report exists to remove + expect(onyxUpdateSpy).not.toHaveBeenCalled(); }); it('navigates to a newly reassigned support ticket', async () => { From ffcde7a43f5e487a0ad5a3acb4733f5a0262e277 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 19:50:32 +0000 Subject: [PATCH 707/781] Update Mobile-Expensify submodule to 5d6cc5d --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 7b114fac24e4..5d6cc5dcd9e2 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 7b114fac24e4a28701357a7ea37c3e9091c5b312 +Subproject commit 5d6cc5dcd9e2c658977bb67599cf5e24b0943c79 From c9610b3e148046e00360970a8ac9c75ae9160903 Mon Sep 17 00:00:00 2001 From: "Yuwen Memon (via MelvinBot)" Date: Wed, 14 Oct 2026 20:10:38 +0000 Subject: [PATCH 708/781] Update NS0068 help articles with new Created From field guidance Co-authored-by: Yuwen Memon --- .../Export-Errors/NS0068-Export-Error.md | 54 +++++++++---- .../Export-Errors/NS0068-Export-Error.md | 77 +++++++++++-------- 2 files changed, 84 insertions(+), 47 deletions(-) diff --git a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md index 39bd550f1bad..d8a66b720cf8 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md +++ b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md @@ -1,17 +1,21 @@ --- title: NS0068 Export Error in NetSuite Integration -description: Learn how to fix the NS0068 export error in NetSuite when the Created From field is not visible on the preferred transaction form. -keywords: NS0068, NetSuite Created From field not visible, permission to set value Created From, NetSuite transaction form visibility, Expensify Card journal entry error, Expensify NetSuite export error, Workspace Admin -internalScope: Audience is Workspace Admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field not being visible on the preferred NetSuite form. Does not cover other NetSuite error codes. +description: Learn how to fix the NS0068 export error in NetSuite when Expensify can't set the Created From field (custbody_createdfrom_expensify) during export. +keywords: NS0068, NetSuite Created From field not visible, custbody_createdfrom_expensify, permission to set value Created From, Transaction Body Fields NetSuite, NetSuite transaction form visibility, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite export error, Workspace Admin +internalScope: Audience is Workspace Admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the preferred form, not being editable by the Expensify role, or missing from NetSuite. Does not cover other NetSuite error codes. --- + + # NS0068 Export Error in NetSuite Integration If you see the error: -NS0068: You do not have permission to set value for element — 'Created From'. +NS0068: NetSuite won't let Expensify set the 'Created From' field (custbody_createdfrom_expensify). + +This means NetSuite is blocking Expensify from setting the **Created From** field during export. -This means the **Created From** field is not visible on the preferred transaction form in NetSuite. +The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. It is added to NetSuite by the **Expensify Connect bundle**. --- @@ -19,8 +23,10 @@ This means the **Created From** field is not visible on the preferred transactio The NS0068 error occurs when: +- The **Created From** field does not apply to the record type you're exporting to (for example, vendor bills, expense reports, or journal entries). - The **Created From** field is hidden on the preferred export form. -- The form does not allow the integration to populate the Created From field. +- The role used by the Expensify connection can't edit the **Created From** field. +- The **Created From** field is missing because the **Expensify Connect bundle** is not installed or is out of date. - Expensify Card transactions are exported as journal entries and the Journal Entry form does not show the Created From field. Expensify must be able to write to the Created From field during export. @@ -29,6 +35,21 @@ Expensify must be able to write to the Created From field during export. ## How to Fix the NS0068 Export Error +### Check the Created From Field Settings + +1. Log in to **NetSuite** as an Administrator. +2. Go to **Customization**. +3. Select **Lists, Records, & Fields**. +4. Click **Transaction Body Fields**. +5. Open the field with the ID **custbody_createdfrom_expensify**. +6. On the **Applies To** tab, confirm the field applies to the record type you're exporting to. +7. On the **Access** tab, confirm the role used by the Expensify connection has **Edit** access to the field. +8. Save the field. + +If you can't find **custbody_createdfrom_expensify**, update or reinstall the **Expensify Connect bundle** in NetSuite before continuing. + +--- + ### If Exporting Vendor Bills or Standard Transactions 1. Log in to **NetSuite** as an Administrator. @@ -54,14 +75,15 @@ Then in Expensify: ### If Exporting Expensify Card Transactions as Journal Entries 1. Log in to **NetSuite**. -2. Go to **Customization**. -3. Select **Forms**. -4. Click **Transaction Forms**. -5. Click **Edit** next to the preferred **Journal Entry** form. -6. Go to **Screen Fields**. -7. Open the **Main** tab. -8. Ensure the **Created From** field is marked as **Show**. -9. Save the form. +2. Confirm **custbody_createdfrom_expensify** applies to journal entries, using the steps in **Check the Created From Field Settings** above. +3. Go to **Customization**. +4. Select **Forms**. +5. Click **Transaction Forms**. +6. Click **Edit** next to the preferred **Journal Entry** form. +7. Go to **Screen Fields**. +8. Open the **Main** tab. +9. Ensure the **Created From** field is marked as **Show**. +10. Save the form. Then in Expensify: @@ -78,8 +100,8 @@ Then in Expensify: ## Does NS0068 Mean My Integration Is Broken? -No. This error is caused by form visibility settings in NetSuite. +No. This error is caused by the Created From field settings, form visibility, or role access in NetSuite. ## Do I Need to Reconnect NetSuite? -No. Updating the form settings and running **Sync** is typically sufficient. +No. Updating the field and form settings and running **Sync** is typically sufficient. If the field is missing, update or reinstall the Expensify Connect bundle first. diff --git a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md index ae03aac56e27..4e5540cf2d8c 100644 --- a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md +++ b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md @@ -1,19 +1,21 @@ --- title: NS0068 Export Error in NetSuite Integration -description: Learn what the NS0068 export error means and how to make the Created From field visible on NetSuite transaction forms before exporting. -keywords: NS0068, NetSuite Created From field error, permission to set Created From NetSuite, transaction form visibility NetSuite, Expensify Card journal entry error, Expensify NetSuite integration, Workspace Admin -internalScope: Audience is Workspace Admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field not being visible on NetSuite transaction forms. Does not cover role permission or token configuration issues. +description: Learn what the NS0068 export error means and how to configure the Created From field (custbody_createdfrom_expensify) in NetSuite so Expensify can set it during export. +keywords: NS0068, NetSuite Created From field error, custbody_createdfrom_expensify, permission to set Created From NetSuite, Transaction Body Fields NetSuite, transaction form visibility NetSuite, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite integration, Workspace Admin +internalScope: Audience is Workspace Admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the preferred form, not being editable by the Expensify role, or missing from NetSuite. Does not cover token configuration issues. --- + + # NS0068 Export Error in NetSuite Integration If you see the error: -NS0068 Export Error: You do not have permission to set value for element — 'Created From'. +NS0068 Export Error: NetSuite won't let Expensify set the 'Created From' field (custbody_createdfrom_expensify). -This means the **Created From** field is not visible on the preferred export form in NetSuite. +This means NetSuite is blocking Expensify from setting the **Created From** field during export. -The Workspace must be able to access and populate the Created From field during export. +The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. It is added to NetSuite by the **Expensify Connect bundle**. --- @@ -21,31 +23,42 @@ The Workspace must be able to access and populate the Created From field during The NS0068 error typically occurs when: -- The **Created From** field is hidden on the NetSuite transaction form used for exports. -- The preferred transaction form does not allow the field to be displayed. -- NetSuite blocks the integration from setting the field value because it is not visible. - -If the field is hidden, NetSuite prevents the integration from setting its value and the export fails. +- The **Created From** field does not apply to the record type you're exporting to (for example, vendor bills, expense reports, or journal entries). +- The **Created From** field is hidden on the transaction form marked as **Preferred** for your export type. +- The role used by the Expensify connection can't edit the **Created From** field. +- The **Created From** field is missing from NetSuite because the **Expensify Connect bundle** is not installed or is out of date. -This is a transaction form visibility issue, not a role permission or token configuration issue. +If any of these apply, NetSuite prevents the integration from setting the field value and the export fails. --- ## How to Fix the NS0068 Export Error -Follow the steps below to make the Created From field visible. +Follow the steps below to check the field setup, form visibility, and role access in NetSuite. --- -## Make the Created From Field Visible in NetSuite +## Check the Created From Field Settings in NetSuite 1. Log in to NetSuite as an administrator. -2. Go to **Customization > Forms > Transaction Forms**. -3. Locate the transaction form marked as **Preferred** for your export type. -4. Click **Edit**. -5. Locate the **Created From** field. -6. Ensure the field is marked as **Show**. -7. Click **Save**. +2. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields**. +3. Open the field with the ID **custbody_createdfrom_expensify**. +4. On the **Applies To** tab, confirm the field applies to the record type you're exporting to. +5. On the **Access** tab, confirm the role used by the Expensify connection has **Edit** access to the field. +6. Click **Save**. + +If you can't find **custbody_createdfrom_expensify**, [update the Expensify Connect bundle](https://help.expensify.com/articles/new-expensify/connections/netsuite/Update-Expensify-Connect-Bundle-in-NetSuite), or reinstall it, before continuing. + +--- + +## Make the Created From Field Visible on the Preferred Form + +1. Go to **Customization > Forms > Transaction Forms**. +2. Locate the transaction form marked as **Preferred** for your export type. +3. Click **Edit**. +4. Locate the **Created From** field. +5. Ensure the field is marked as **Show**. +6. Click **Save**. Confirm the field is visible on the preferred form before proceeding. @@ -53,7 +66,7 @@ Confirm the field is visible on the preferred form before proceeding. ## Sync the Workspace in Expensify -After updating the form: +After updating NetSuite: On web: @@ -78,19 +91,21 @@ On mobile: 1. Open the report. 2. Retry exporting to NetSuite. -If the Created From field is visible on the preferred form, the export should complete successfully. +If the Created From field applies to your export type, is visible on the preferred form, and is editable by the Expensify role, the export should complete successfully. --- # Additional Steps for Expensify Card Journal Entries -If you are exporting **Expensify Card transactions as journal entries**, confirm the Created From field is visible on the journal entry form. +If you are exporting **Expensify Card transactions as journal entries**, confirm the Created From field is set up for journal entries. -1. Go to **Customization > Forms > Transaction Forms**. -2. Click **Edit** next to the preferred journal entry form. -3. Go to **Screen Fields > Main**. -4. Confirm **Created From** is marked as **Show**. -5. Click **Save**. +1. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields** and open **custbody_createdfrom_expensify**. +2. On the **Applies To** tab, confirm the field applies to journal entries, then click **Save**. +3. Go to **Customization > Forms > Transaction Forms**. +4. Click **Edit** next to the preferred journal entry form. +5. Go to **Screen Fields > Main**. +6. Confirm **Created From** is marked as **Show**. +7. Click **Save**. Then return to the Workspace and select **Sync Now** before retrying the export. @@ -100,12 +115,12 @@ Then return to the Workspace and select **Sync Now** before retrying the export. ## Does the NS0068 Export Error Affect All Export Types? -It affects exports using a transaction form where the Created From field is hidden. +It affects any export type where the Created From field doesn't apply to the record, is hidden on the preferred form, or isn't editable by the Expensify role. ## Do I Need NetSuite Admin Access to Fix the NS0068 Export Error? -Yes. Updating transaction form field visibility requires NetSuite administrator permissions. +Yes. Updating custom field settings and transaction form visibility requires NetSuite administrator permissions. ## Do I Need to Reconnect the Integration? -No. Making the Created From field visible and selecting **Sync Now** is typically sufficient. +No. Updating the Created From field settings and selecting **Sync Now** is typically sufficient. If the field is missing, update or reinstall the Expensify Connect bundle first. From 079c8c96d3cc073a6ac014464bfba673d8a814e5 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 20:20:51 +0000 Subject: [PATCH 709/781] Update Mobile-Expensify submodule to 721a306 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 5d6cc5dcd9e2..721a306874d5 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 5d6cc5dcd9e2c658977bb67599cf5e24b0943c79 +Subproject commit 721a306874d56225db761ce67429a8fee35dfb6a From 0a90839842f34e8c645258d85f595012279535e8 Mon Sep 17 00:00:00 2001 From: "Fitsum Abebe (via MelvinBot)" Date: Wed, 14 Oct 2026 20:21:19 +0000 Subject: [PATCH 710/781] Run the header back logic on Android back from onboarding work email Android back now uses the header's check (join intent, not the Concierge task flow) instead of the route index, and goes back to the intent list the same way. This keeps the user in onboarding after a cold start, when work email is the only route. Co-authored-by: Fitsum Abebe --- .../BaseOnboardingWorkEmail.tsx | 35 ++++++++-- .../OnboardingWorkEmail/index.native.tsx | 29 +------- tests/ui/WorkEmailOnboardingNativeBack.tsx | 66 ++++++++++++++++--- 3 files changed, 87 insertions(+), 43 deletions(-) diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 74b567b1432c..06737981d18d 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -11,6 +11,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -303,6 +304,31 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW handleMergingAccountBlockedConfirm = handleRegularJoinWorkspaceErrorConfirm; } + // This screen normally opens onboarding, so there is nothing to go back to unless the intent list sent us here. + // The intent stays saved across a cold start, so check it instead of the route index. + const canGoBackToPurpose = isJoiningCompanyWorkspace && !isConciergeTaskFlow; + + const goBackToPurpose = () => { + // Clear any work email error so the purpose screen doesn't render it without its translation params. + clearOnboardingMergeAccountBlocked(); + // Pops to the intent list when it's in the stack, or replaces this screen with it, so the user stays in onboarding. + Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute()); + }; + + const handleAndroidBackPress = () => { + if (canGoBackToPurpose) { + goBackToPurpose(); + return true; + } + + // Otherwise there is no earlier step, so keep the press from closing onboarding and tell the user to finish setup. + setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); + return true; + }; + + // Android's system back runs the same logic as the header back button. + useAndroidBackButtonHandler(handleAndroidBackPress); + const shouldRenderOfflineFeedback = useCallback((errorTranslation: string) => { if ( errorTranslation !== 'onboarding.workEmail2FAError' && @@ -371,14 +397,9 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW testID="BaseOnboardingWorkEmail" style={[styles.defaultModalContainer, shouldUseNativeStyles && styles.pt8]} > - {/* This screen normally opens onboarding, so there is nothing to go back to unless the intent list sent us here. */} { - // Clear any work email error so the purpose screen doesn't render it without its translation params. - clearOnboardingMergeAccountBlocked(); - Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute()); - }} + shouldShowBackButton={canGoBackToPurpose} + onBackButtonPress={goBackToPurpose} shouldShowCloseButton={isConciergeTaskFlow} onCloseButtonPress={onboardingValues?.isMergingAccountBlocked ? handleConciergeTaskErrorConfirm : returnToOriginReport} /> diff --git a/src/pages/OnboardingWorkEmail/index.native.tsx b/src/pages/OnboardingWorkEmail/index.native.tsx index 693c128fc7f3..bec1f099003b 100644 --- a/src/pages/OnboardingWorkEmail/index.native.tsx +++ b/src/pages/OnboardingWorkEmail/index.native.tsx @@ -1,37 +1,10 @@ -import {clearOnboardingMergeAccountBlocked, setOnboardingErrorMessage} from '@userActions/Welcome'; - -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback} from 'react'; -import {BackHandler} from 'react-native'; +import React from 'react'; import type {OnboardingWorkEmailProps} from './types'; import BaseOnboardingWorkEmail from './BaseOnboardingWorkEmail'; function OnboardingWorkEmail(props: OnboardingWorkEmailProps) { - const {index: routeIndex} = props.navigation.getState(); - - // To block android native back button behavior - useFocusEffect( - useCallback(() => { - const backAction = () => { - // We don't want to block the back button if this is not the first route, e.g. when the intent list sent us here. - // Clear any work email error like the header back button does, and return false so navigation still pops the screen. - if (routeIndex !== 0) { - clearOnboardingMergeAccountBlocked(); - return false; - } - - // Return true to indicate that the back button press is handled here - setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); - return true; - }; - - const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); - - return () => backHandler.remove(); - }, [routeIndex]), - ); return ( ; } +// Stands in for the intent list; only whether back lands on it matters. +function OnboardingPurposeStub() { + return ; +} + // The native (`index.native.tsx`) screens are imported directly: only those register the hardware back handler under // test, and Jest resolves the web `index.tsx` for a bare `@pages/...` import. function OnboardingModalNavigator() { return ( + { expect(errorMessage).toBe('onboarding.purpose.errorBackButton'); }); - it('should let a system back press through when the join workspace intent opened work email', async () => { - // Given the intent list sent the user to work email after they picked "Join my company workspace", so there - // is a real screen behind this one + it('should go back to the intent list when the join workspace intent opened work email', async () => { + // Given the intent list sent the user to work email after they picked "Join my company workspace", so the + // intent list is behind this screen await TestHelper.signInWithTestUser(); await act(async () => { @@ -411,7 +420,7 @@ describe('Onboarding work email validation (Android system back)', () => { await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); }); - renderOnboardingStack([SCREENS.ONBOARDING.PERSONAL_DETAILS]); + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE]); await waitForBatchedUpdatesWithAct(); @@ -423,7 +432,7 @@ describe('Onboarding work email validation (Android system back)', () => { await waitForBatchedUpdatesWithAct(); - expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PERSONAL_DETAILS, SCREENS.ONBOARDING.WORK_EMAIL]); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL]); // Given adding the work email failed, leaving errors that the intent list would render if they followed the user back await act(async () => { @@ -435,13 +444,14 @@ describe('Onboarding work email validation (Android system back)', () => { await waitForBatchedUpdatesWithAct(); // When Android's system back button is pressed - pressHardwareBack(); + const consumed = pressHardwareBack(); await waitForBatchedUpdatesWithAct(); - // Then no handler swallows it, the onboarding stack pops as normal, and no error is left behind + // Then the screen handles the press like the header back button: it returns to the intent list and no error is left behind + expect(consumed).toBe(true); await waitFor(() => { - expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PERSONAL_DETAILS]); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE]); }); let errorMessage: OnyxEntry; await TestHelper.getOnyxData({ @@ -454,4 +464,44 @@ describe('Onboarding work email validation (Android system back)', () => { expect((await getOnboardingValues())?.isMergingAccountBlocked).toBe(false); expect((await getOnyxValue(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM))?.errors).toBeFalsy(); }); + + it('should replace work email with the intent list when the join workspace intent is saved but work email is the only route', async () => { + // Given the user picked "Join my company workspace" and then reopened the app, which rebuilds the onboarding + // stack with work email as its only route + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + }); + + renderOnboardingStack([SCREENS.ONBOARDING.WORK_EMAIL]); + + await waitForBatchedUpdatesWithAct(); + + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.WORK_EMAIL]); + + // When Android's system back button is pressed + const consumed = pressHardwareBack(); + + await waitForBatchedUpdatesWithAct(); + + // Then the press is handled here so it cannot close the onboarding modal, and the intent list replaces work + // email, matching the header back button + expect(consumed).toBe(true); + await waitFor(() => { + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE]); + }); + + // Then the "finish setup" error is not shown, because the user had somewhere to go back to + let errorMessage: OnyxEntry; + await TestHelper.getOnyxData({ + key: ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY, + callback: (value) => { + errorMessage = value ?? undefined; + }, + }); + expect(errorMessage).toBeUndefined(); + }); }); From 3ab7afe9a13bc81040dedd33bc14d5061b415a6e Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 13:28:12 -0700 Subject: [PATCH 711/781] test(concierge): preserve feedback acknowledgement timers --- tests/ui/ReportActionItemTest.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/ui/ReportActionItemTest.tsx b/tests/ui/ReportActionItemTest.tsx index b55b26eb8891..c1ec5a1bc33e 100644 --- a/tests/ui/ReportActionItemTest.tsx +++ b/tests/ui/ReportActionItemTest.tsx @@ -3604,8 +3604,8 @@ describe('ReportActionItem', () => { async function reactWithThumbsUp(action: ReportAction, timestamp: string) { await act(async () => { - await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACTOR_ACCOUNT_ID}); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, buildThumbsUpReaction(timestamp)); + await Onyx.set(ONYXKEYS.SESSION, {accountID: ACTOR_ACCOUNT_ID}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, buildThumbsUpReaction(timestamp)); }); } @@ -3636,10 +3636,12 @@ describe('ReportActionItem', () => { jest.useFakeTimers(); try { + // Given an unrated Concierge reply, the customer can leave feedback. const action = createConciergeComment(); renderItemWithAction(action, true); await waitForBatchedUpdatesWithAct(); + // When the customer rates it, acknowledge their feedback for four seconds. await reactWithThumbsUp(action, '2025-07-12 09:03:18.000'); expect(screen.getByText(translateLocal('concierge.feedback.thanks'))).toBeOnTheScreen(); @@ -3651,6 +3653,7 @@ describe('ReportActionItem', () => { await clearReactions(action); expect(screen.getByText(prompt())).toBeOnTheScreen(); + // Then restoring the rating immediately does not repeat the acknowledgement. await reactWithThumbsUp(action, '2025-07-12 09:03:19.000'); expect(screen.queryByText(translateLocal('concierge.feedback.thanks'))).not.toBeOnTheScreen(); } finally { From ee932370941b5e7df91378721d22e8d1f8a5f7d5 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Mon, 12 Oct 2026 12:02:07 -0700 Subject: [PATCH 712/781] fix(rules): reject a generated rule of another rule type --- .../workspace/rules/DescribeRuleModal.tsx | 9 +- src/types/onyx/GeneratedRule.ts | 3 + tests/ui/DescribeRuleModalTest.tsx | 202 ++++++++++++++++++ 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 tests/ui/DescribeRuleModalTest.tsx diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx index 89762fb17839..8df9647d37be 100644 --- a/src/pages/workspace/rules/DescribeRuleModal.tsx +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -71,6 +71,7 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const generatedRuleID = generatedRule?.generationID; const generatedRuleState = generatedRule?.state; + const generatedRuleType = generatedRule?.ruleType; const generatedRuleValues = generatedRule?.rule; const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); @@ -89,9 +90,15 @@ function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenera clearGeneratedRule(); return; } + // Concierge picks the rule type itself. It can answer with a rule type other than the one this editor asked for. + if (generatedRuleType !== ruleType) { + setNewRulePromptError(CONST.GENERATED_RULE.STATE.UNSUPPORTED); + clearGeneratedRule(); + return; + } onRuleGenerated(withPolicyCategory(generatedRuleValues ?? {}, policyCategories)); close(); - }, [close, generatedRuleID, generatedRuleState, generatedRuleValues, onRuleGenerated, policyCategories]); + }, [close, generatedRuleID, generatedRuleState, generatedRuleType, generatedRuleValues, onRuleGenerated, policyCategories, ruleType]); useEffect(() => { if (!isVisible || !newRulePromptForm?.isLoading || pendingGenerationIDRef.current) { diff --git a/src/types/onyx/GeneratedRule.ts b/src/types/onyx/GeneratedRule.ts index f7543798f047..fd156e2388a0 100644 --- a/src/types/onyx/GeneratedRule.ts +++ b/src/types/onyx/GeneratedRule.ts @@ -20,6 +20,9 @@ type GeneratedRule = { /** The outcome of the generation attempt */ state: GeneratedRuleState; + /** The rule type the description was mapped to */ + ruleType?: GeneratedRuleType; + /** The form values to seed the rule draft with */ rule?: GeneratedRuleValues; }; diff --git a/tests/ui/DescribeRuleModalTest.tsx b/tests/ui/DescribeRuleModalTest.tsx new file mode 100644 index 000000000000..0a2f84071357 --- /dev/null +++ b/tests/ui/DescribeRuleModalTest.tsx @@ -0,0 +1,202 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import * as API from '@libs/API'; +import type {GenerateRuleParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; + +import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {GeneratedRule} from '@src/types/onyx'; +import type {GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; + +import type * as ReactNavigationNative from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import {translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockPrompt = ''; + +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useFocusEffect: (callback: () => void) => { + callback(); + }, + }; +}); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({isSmallScreenWidth: false, shouldUseNarrowLayout: false}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@components/Text', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({children}: {children: React.ReactNode}) => ReactModule.createElement(Text, null, children); +}); +jest.mock( + '@components/Modal', + () => + ({isVisible, children}: {isVisible: boolean; children: React.ReactNode}) => + isVisible ? children : null, +); +jest.mock('@components/Form/InputWrapper', () => () => null); +jest.mock('@components/Form/FormProvider', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + return ({children, onSubmit, submitButtonText}: {children: React.ReactNode; onSubmit: (values: {prompt: string}) => void; submitButtonText: string}) => + ReactModule.createElement( + ReactModule.Fragment, + null, + children, + ReactModule.createElement( + Pressable, + {accessibilityLabel: submitButtonText, onPress: () => onSubmit({prompt: mockPrompt})}, + ReactModule.createElement(Text, null, submitButtonText), + ), + ); +}); + +const POLICY_ID = 'policy1'; + +type WriteSpy = jest.SpiedFunction; + +function isGenerateRuleParams(parameters: unknown): parameters is GenerateRuleParams { + return typeof parameters === 'object' && parameters !== null && 'generationID' in parameters && 'prompt' in parameters; +} + +async function renderFlagForReviewModal(onRuleGenerated: jest.Mock, onClose: jest.Mock) { + render( + , + ); + await waitForBatchedUpdatesWithAct(); +} + +/** Submits the prompt through the modal and returns the generationID of the GenerateRule request */ +function describeRule(writeSpy: WriteSpy, prompt: string): string { + mockPrompt = prompt; + fireEvent.press(screen.getByLabelText('common.next')); + const parameters = writeSpy.mock.calls.find(([command]) => command === WRITE_COMMANDS.GENERATE_RULE)?.at(1); + if (!isGenerateRuleParams(parameters)) { + throw new Error('The describe modal did not request GenerateRule'); + } + return parameters.generationID; +} + +async function answerGeneration(answer: GeneratedRule) { + await act(async () => { + await Onyx.set(ONYXKEYS.GENERATED_RULE, answer); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function getPromptErrors() { + const form = await getOnyxValue(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + return Object.values(form?.errors ?? {}); +} + +describe('DescribeRuleModal', () => { + let writeSpy: WriteSpy; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrompt = ''; + writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + await act(async () => { + await Onyx.clear(); + }); + }); + + it('gives the generated values to the editor and closes when the answer has the rule type of the editor', async () => { + // Given the flag for review editor asked for a rule + const onRuleGenerated = jest.fn(); + const onClose = jest.fn(); + await renderFlagForReviewModal(onRuleGenerated, onClose); + const generationID = describeRule(writeSpy, 'Flag expenses over $500'); + + // When Concierge answers with a flag for review rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW, + rule: {maxExpenseAmount: '500'}, + }); + + // Then the editor gets the values and the modal closes, because the editor can show them + expect(onRuleGenerated).toHaveBeenCalledWith({maxExpenseAmount: '500'}); + expect(onClose).toHaveBeenCalled(); + }); + + it.each([ + {description: 'another rule type', generatedRuleType: CONST.GENERATED_RULE.RULE_TYPE.RESTRICT_CARD_SPEND}, + {description: 'no rule type', generatedRuleType: undefined}, + ])('shows the unsupported error and stays open when the answer has $description', async ({generatedRuleType}: {generatedRuleType: GeneratedRuleType | undefined}) => { + // Given the flag for review editor asked for a rule + const onRuleGenerated = jest.fn(); + const onClose = jest.fn(); + await renderFlagForReviewModal(onRuleGenerated, onClose); + const generationID = describeRule(writeSpy, 'Block retail purchases on all cards'); + + // When Concierge answers with a rule that is not a flag for review rule + await answerGeneration({ + generationID, + state: CONST.GENERATED_RULE.STATE.RULE, + ruleType: generatedRuleType, + rule: {merchantNames: ['Walmart']}, + }); + + // Then the editor does not get the values, because they would replace its form with the fields of another rule type + expect(onRuleGenerated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unsupported')]); + expect(await getOnyxValue(ONYXKEYS.GENERATED_RULE)).toBeUndefined(); + }); + + it('shows the error of an answer that is not a rule', async () => { + // Given the flag for review editor asked for a rule + const onRuleGenerated = jest.fn(); + const onClose = jest.fn(); + await renderFlagForReviewModal(onRuleGenerated, onClose); + const generationID = describeRule(writeSpy, 'asdf qwerty zxcv'); + + // When Concierge answers that the description is unintelligible + await answerGeneration({generationID, state: CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE}); + + // Then the modal shows the unintelligible error, because the rule type check applies only to a rule answer + expect(onRuleGenerated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(await getPromptErrors()).toEqual([translateLocal('workspace.rules.newRule.promptErrors.unintelligible')]); + }); +}); From 7a12f5f189177b0a7562f9126f1a494503bba071 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 20:43:36 +0000 Subject: [PATCH 713/781] Update version to 9.5.9-1 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1e2ae1792635..d46e331cc78c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050900 - versionName "9.5.9-0" + versionCode 1009050901 + versionName "9.5.9-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index fe48fd5b6740..59b836c9b220 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.9.0 + 9.5.9.1 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index cb1eb32d576d..014041ab93a8 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.9 CFBundleVersion - 9.5.9.0 + 9.5.9.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index d003273820b7..df49eadc0625 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.9 CFBundleVersion - 9.5.9.0 + 9.5.9.1 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 31e8c0b6a645..5e9b46731c13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.9-0", + "version": "9.5.9-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.9-0", + "version": "9.5.9-1", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 828a2394a256..2de9b07868c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.9-0", + "version": "9.5.9-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From f4ecc5a63d12ff9beee95ada1d8de2429180e982 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 20:43:37 +0000 Subject: [PATCH 714/781] Update Mobile-Expensify submodule version to 9.5.9-1 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 721a306874d5..ef32984fc009 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 721a306874d56225db761ce67429a8fee35dfb6a +Subproject commit ef32984fc0091a867c96e488362daf78acfc3fa9 From b77fcc0574e206d45308a5f9603bb38ded02680f Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 13:43:53 -0700 Subject: [PATCH 715/781] fix(support): render pending tickets without fetching early --- src/libs/actions/Report/index.ts | 19 ++++++++++- src/pages/inbox/ReportFetchHandler.tsx | 11 +++--- tests/actions/SupportTicketTest.ts | 47 +++++++++++++++++++++++++- tests/unit/ReportFetchHandlerTest.tsx | 40 ++++++++++++++++++++++ tests/unit/ReportNameUtilsTest.ts | 11 ++++++ 5 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 8ab475c25cea..7c8ea2075630 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -4904,7 +4904,19 @@ function openSupportTicket(resolvedSupportTicketReportID?: string) { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, - value: {type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + value: {isOptimisticReport: true}, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + value: {isOptimisticReport: false}, }, ], failureData: [ @@ -4913,6 +4925,11 @@ function openSupportTicket(resolvedSupportTicketReportID?: string) { key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, value: null, }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + value: null, + }, ], } : {}; diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 7b250efc7157..54e41fc4d2ad 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -190,9 +190,8 @@ function ReportFetchHandler() { // isPendingCreationFromRoute means reportIDFromRoute is a client-generated ID that doesn't exist // on the server yet. Calling openReport for it would 403 and show the not-found page instead. - // Once the real submit creates the report under this ID, reportOnyx?.reportID becomes truthy - // and normal fetching resumes. - if (isPendingCreationFromRoute && !reportOnyx?.reportID) { + // Wait until the report exists locally and is no longer optimistic before fetching it. + if (isPendingCreationFromRoute && (!reportOnyx?.reportID || reportMetadata.isOptimisticReport)) { return; } @@ -387,15 +386,15 @@ function ReportFetchHandler() { navigation.setParams({secureKey: undefined}); }, [secureKeyFromRoute, reportIDFromRoute, report?.reportID, report?.errorFields?.notFound, navigation]); - // isPendingCreation is only needed before the report exists. Clear it from the route params once it + // isPendingCreation is only needed before the report exists on the server. Clear it from the route params once it // does, so a copied URL, restored navigation state, or reload doesn't carry the stale flag and skip // fetching again later. useEffect(() => { - if (!isPendingCreationFromRoute || !reportOnyx?.reportID) { + if (!isPendingCreationFromRoute || !reportOnyx?.reportID || reportMetadata.isOptimisticReport) { return; } navigation.setParams({isPendingCreation: undefined}); - }, [isPendingCreationFromRoute, reportOnyx?.reportID, navigation]); + }, [isPendingCreationFromRoute, reportOnyx?.reportID, reportMetadata.isOptimisticReport, navigation]); // A restored or shared route can carry isPendingCreation with no submit behind it. The owning submit writes the // report row within the dismiss transition budget (about 2s) after this screen gains focus, so a focused screen diff --git a/tests/actions/SupportTicketTest.ts b/tests/actions/SupportTicketTest.ts index 1677429fa9a6..7375a4f9e99e 100644 --- a/tests/actions/SupportTicketTest.ts +++ b/tests/actions/SupportTicketTest.ts @@ -66,7 +66,19 @@ describe('actions/Report', () => { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, - value: {type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + value: {isOptimisticReport: true}, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + value: {isOptimisticReport: false}, }, ], failureData: [ @@ -75,6 +87,11 @@ describe('actions/Report', () => { key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, value: null, }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + value: null, + }, ], }, ); @@ -95,6 +112,7 @@ describe('actions/Report', () => { reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET, }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, {isOptimisticReport: true}); mockMakeRequestWithSideEffects.mockRejectedValueOnce(networkError); // When the customer asks to talk to a human @@ -103,6 +121,33 @@ describe('actions/Report', () => { // Then the App removes the pending report and preserves the request failure await expect(request).rejects.toBe(networkError); await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + }); + + it.each([CONST.JSON_CODE.SUCCESS, CONST.JSON_CODE.EXP_ERROR])('settles the optimistic ticket after server response %s', async (jsonCode) => { + // Given a new ticket whose creation response applies the API's success or failure updates + mockMakeRequestWithSideEffects.mockImplementationOnce(async (_command, _parameters, onyxData) => { + await Onyx.update(onyxData?.optimisticData ?? []); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toEqual({ + reportID: newSupportTicketReportID, + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + }); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`)).resolves.toEqual({isOptimisticReport: true}); + await Onyx.update((jsonCode === CONST.JSON_CODE.SUCCESS ? onyxData?.successData : onyxData?.failureData) ?? []); + return {jsonCode}; + }); + + // When the server accepts or rejects the creation request + await openSupportTicket(); + + // Then a successful ticket can be fetched, while a rejected ticket leaves no optimistic data behind + if (jsonCode === CONST.JSON_CODE.SUCCESS) { + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`)).resolves.toEqual({isOptimisticReport: false}); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toMatchObject({reportID: newSupportTicketReportID}); + } else { + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + } }); it('does not update Onyx when reopening a support ticket fails', async () => { diff --git a/tests/unit/ReportFetchHandlerTest.tsx b/tests/unit/ReportFetchHandlerTest.tsx index 6372602d5bd3..74319fc7176a 100644 --- a/tests/unit/ReportFetchHandlerTest.tsx +++ b/tests/unit/ReportFetchHandlerTest.tsx @@ -120,6 +120,46 @@ describe('ReportFetchHandler', () => { expect(mockOpenReport).not.toHaveBeenCalled(); }); + it.each([false, true])('keeps an optimistic support ticket pending without fetching it when offline is %s', async (isOffline) => { + // Given a support ticket that can render locally but has not been created on the server + mockIsOffline = isOffline; + setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT_ID}`, {isOptimisticReport: true}); + await waitForBatchedUpdates(); + + // When the customer opens the pending ticket + renderHandler(); + await waitForBatchedUpdates(); + + // Then the client neither requests a nonexistent report nor releases the pending route + expect(mockOpenReport).not.toHaveBeenCalled(); + expect(mockSetParams).not.toHaveBeenCalledWith({isPendingCreation: undefined}); + }); + + it('resumes fetching after the server creates an optimistic support ticket', async () => { + // Given a mounted support ticket waiting for creation + setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT_ID}`, {isOptimisticReport: true}); + await waitForBatchedUpdates(); + const {rerender} = renderHandler(); + await waitForBatchedUpdates(); + expect(mockOpenReport).not.toHaveBeenCalled(); + expect(mockSetParams).not.toHaveBeenCalledWith({isPendingCreation: undefined}); + + // When creation succeeds, the pending route can be released + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT_ID}`, {isOptimisticReport: false}); + await waitForBatchedUpdates(); + expect(mockSetParams).toHaveBeenCalledWith({isPendingCreation: undefined}); + setRouteParams({reportID: REPORT_ID}); + rerender(); + await waitForBatchedUpdates(); + + // Then fetching the server-created report is safe + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID: REPORT_ID})); + }); + it('expires a stale isPendingCreation flag when the focused screen never receives a report row', async () => { // Given a restored route that still carries isPendingCreation but has no submit writing the report setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index 9ae674d6c75c..b3a92392e7eb 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -208,6 +208,17 @@ describe('ReportNameUtils', () => { }); describe('computeReportName - support tickets', () => { + test('shows the support ticket title before server details arrive', () => { + // Given a support ticket that exists only optimistically + const supportTicket = {reportID: 'supportTicketReportID', type: CONST.REPORT.TYPE.SUPPORT_TICKET}; + + // When its name is displayed before creation completes + const name = computeReportName(supportTicket); + + // Then the customer sees a support ticket title without needing participant details + expect(name).toBe('Support ticket'); + }); + test('uses the support rep login when their display name is unavailable', () => { const supportTicket = createMock({ reportID: 'supportTicketReportID', From e09ed77b8cc257bf492b6b0cc7547e2116b944b0 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 13:52:53 -0700 Subject: [PATCH 716/781] fix(support): set the optimistic guard before the report id --- src/libs/actions/Report/index.ts | 11 +++---- tests/actions/SupportTicketTest.ts | 48 ++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 7c8ea2075630..59957ff7fd18 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -4901,15 +4901,16 @@ function openSupportTicket(resolvedSupportTicketReportID?: string) { const onyxData: OnyxData = newSupportTicketReportID ? { optimisticData: [ + // Set the fetch guard before publishing the report ID; merges can wait on storage reads. { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, - value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + value: {isOptimisticReport: true}, }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, - value: {isOptimisticReport: true}, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, }, ], successData: [ diff --git a/tests/actions/SupportTicketTest.ts b/tests/actions/SupportTicketTest.ts index 7375a4f9e99e..9115ebced154 100644 --- a/tests/actions/SupportTicketTest.ts +++ b/tests/actions/SupportTicketTest.ts @@ -64,14 +64,14 @@ describe('actions/Report', () => { { optimisticData: [ { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, - value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + value: {isOptimisticReport: true}, }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, - value: {isOptimisticReport: true}, + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + value: {reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}, }, ], successData: [ @@ -105,6 +105,44 @@ describe('actions/Report', () => { expect(mockNavigate).toHaveBeenCalledTimes(1); }); + it('marks a new ticket optimistic before publishing its report ID', async () => { + // Given a screen observing a ticket that has not been created on the server + let isOptimisticReport = false; + const reportStates: boolean[] = []; + // Observe both keys independently, as the report screen does while creation is pending. + const metadataConnection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + callback: (metadata) => { + isOptimisticReport = !!metadata?.isOptimisticReport; + }, + }); + const reportConnection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`, + callback: (report) => { + if (!report?.reportID) { + return; + } + reportStates.push(isOptimisticReport); + }, + }); + await waitForBatchedUpdates(); + mockMakeRequestWithSideEffects.mockImplementationOnce(async (_command, _parameters, onyxData) => { + await Onyx.update(onyxData?.optimisticData ?? []); + }); + + try { + // When optimistic updates make the pending ticket visible + await openSupportTicket(); + await waitForBatchedUpdates(); + + // Then the report ID is never observable without its fetch guard + expect(reportStates).toEqual([true]); + } finally { + Onyx.disconnect(reportConnection); + Onyx.disconnect(metadataConnection); + } + }); + it('removes the pending report when the request fails before receiving a response', async () => { // Given an optimistic support ticket in Onyx and a request that fails before the API can apply its failure data const networkError = new Error('Network request failed'); From 2403f6727718e20a455805bcd301a094dd194cee Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Wed, 14 Oct 2026 21:05:48 +0000 Subject: [PATCH 717/781] Flush a pending scroll-to-bottom on list content size change instead of on the next frame Co-authored-by: Abdelrahman Khattab --- src/hooks/useReportActionsScroll.ts | 16 +----------- src/pages/inbox/report/ReportActionsList.tsx | 1 + tests/ui/ReportActionsListTest.tsx | 17 ++++++++++++ tests/unit/useReportActionsScrollTest.tsx | 27 -------------------- 4 files changed, 19 insertions(+), 42 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index eef062777d87..dfcd02207383 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -113,7 +113,7 @@ type UseReportActionsScrollResult = { scrollToActionBadgeTarget: () => void; - /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout */ + /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout and content size change */ flushPendingScrollToBottom: () => void; /** Whether the list should be pinned to the visual top (transaction thread / money request) */ @@ -412,20 +412,6 @@ function useReportActionsScroll({ completeLiveTailPruneAfterScrollToBottom(); }; - const flushPendingScrollToBottomFromEffect = useEffectEvent(() => flushPendingScrollToBottom()); - - // In an aligned-to-top report (expense, invoice, transaction thread), the list's onLayout is not enough to flush a - // pending scroll: a new comment changes the content size, not the layout size, so onLayout may never fire. Regular - // chats don't need this, because their first scrollToBottom already lands. Flush on the next frame so this effect - // doesn't chain a synchronous setState. - useEffect(() => { - if (!shouldBeAlignedToTop || !isScrollToBottomEnabled) { - return; - } - const frame = requestAnimationFrame(() => flushPendingScrollToBottomFromEffect()); - return () => cancelAnimationFrame(frame); - }, [shouldBeAlignedToTop, isScrollToBottomEnabled]); - // Data is ready at the moment FlashList finishes its first render. const onLoad = () => { if (shouldDisablePillTracking) { diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e240e744651e..c19330f2baa4 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -530,6 +530,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct onLoad={onLoad} onContentSizeChange={() => { trackVerticalScrolling(undefined); + flushPendingScrollToBottom(); }} /> diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 9b5234228c42..d4b59a15a428 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -160,6 +160,7 @@ type MockInvertedFlashListProps = { data?: OnyxTypes.ReportAction[]; extraData?: unknown; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; + onContentSizeChange?: () => void; }; const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/FlashList/InvertedFlashList'); @@ -830,6 +831,22 @@ describe('ReportActionsList (body)', () => { }); }); + describe('Pending scroll-to-bottom', () => { + it('flushes a pending scroll-to-bottom when the list content size changes', () => { + // Given the list is rendered + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + // When the list content size changes, as it does once FlashList lays out a newly sent comment. In an + // aligned-to-top report this is the only list event that follows, because onLayout does not fire. + getCapturedListProps()?.onContentSizeChange?.(); + + // Then the pending scroll-to-bottom is flushed, so the list lands on the new comment + const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as {flushPendingScrollToBottom: jest.Mock}; + expect(scrollHookResult.flushPendingScrollToBottom).toHaveBeenCalledTimes(1); + }); + }); + describe('Concierge Side Panel', () => { const CONCIERGE_REPORT_ID = '123'; const CURRENT_USER_ACCOUNT_ID = 100; diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index e165349e8f12..c3e78bccf919 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -433,33 +433,6 @@ describe('useReportActionsScroll', () => { expect(mockSetIsScrollToBottomEnabled).toHaveBeenCalledWith(false); expect(mockCompleteLiveTailPrune).toHaveBeenCalledTimes(1); }); - - it('flushes a pending scroll-to-bottom without waiting for a list layout event in an aligned-to-top report', async () => { - // Given an expense report, and the live-tail hook asked for a scroll to bottom, as it does after the user sends a comment - mockIsMoneyRequestReport = true; - mockIsScrollToBottomEnabled = true; - - // When the hook renders and no list onLayout follows, because a new comment changes content size, not layout size - await renderScroll(); - - // Then the pending scroll is still flushed once, and the request is cleared - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); - expect(mockSetIsScrollToBottomEnabled).toHaveBeenCalledWith(false); - expect(mockCompleteLiveTailPrune).toHaveBeenCalledTimes(1); - }); - - it('leaves a pending scroll-to-bottom to the list layout event in a regular chat', async () => { - // Given a regular chat, and the live-tail hook asked for a scroll to bottom - mockIsScrollToBottomEnabled = true; - - // When the hook renders - await renderScroll(); - - // Then nothing flushes on the next frame, because the first scrollToBottom in a regular chat already lands - expect(mockScrollToBottom).not.toHaveBeenCalled(); - expect(mockSetIsScrollToBottomEnabled).not.toHaveBeenCalled(); - expect(mockCompleteLiveTailPrune).not.toHaveBeenCalled(); - }); }); describe('onLoad', () => { From ce7e6df2a8fad1bdeddb5485b2a7465bb1668ad2 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 15:18:55 -0700 Subject: [PATCH 718/781] fix(support): release pending ticket guards after reload --- src/libs/actions/Report/index.ts | 15 +++--- src/pages/inbox/ReportFetchHandler.tsx | 11 ++-- src/types/onyx/ReportLoadingState.ts | 3 ++ tests/actions/SupportTicketTest.ts | 70 ++++++++++++++++---------- tests/unit/ReportFetchHandlerTest.tsx | 33 ++++++++++-- 5 files changed, 91 insertions(+), 41 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 59957ff7fd18..0a284ebd4dd5 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -4898,14 +4898,15 @@ function isNoSupportRepAvailableResponse(response: {jsonCode?: number | string; function openSupportTicket(resolvedSupportTicketReportID?: string) { const newSupportTicketReportID = resolvedSupportTicketReportID ? undefined : generateReportID(); const parameters: CreateSupportTicketParams = resolvedSupportTicketReportID ? {resolvedSupportTicketReportID, idempotencyKey: Str.guid()} : {newSupportTicketReportID}; - const onyxData: OnyxData = newSupportTicketReportID + const onyxData: OnyxData = newSupportTicketReportID ? { optimisticData: [ - // Set the fetch guard before publishing the report ID; merges can wait on storage reads. + // The request does not survive reloads, so its fetch guard must stay in RAM. + // Set it before publishing the report ID; merges can wait on storage reads. { onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, - value: {isOptimisticReport: true}, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: true}, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -4916,8 +4917,8 @@ function openSupportTicket(resolvedSupportTicketReportID?: string) { successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, - value: {isOptimisticReport: false}, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: false}, }, ], failureData: [ @@ -4928,7 +4929,7 @@ function openSupportTicket(resolvedSupportTicketReportID?: string) { }, { onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, value: null, }, ], diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 54e41fc4d2ad..ff189b4b44e0 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -77,6 +77,7 @@ const defaultReportMetadata = { const defaultReportLoadingState = { hasOnceLoadedReportActions: false, + isCreatingSupportTicket: false, isLoadingInitialReportActions: true, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false, @@ -129,6 +130,7 @@ function ReportFetchHandler() { const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportOnyx?.chatReportID}`); const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`); const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); + const isPendingReportCreation = !!reportMetadata.isOptimisticReport || !!reportLoadingState.isCreatingSupportTicket; const isReportActionsLoaded = useIsReportActionsLoaded(reportIDFromRoute); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -191,7 +193,7 @@ function ReportFetchHandler() { // isPendingCreationFromRoute means reportIDFromRoute is a client-generated ID that doesn't exist // on the server yet. Calling openReport for it would 403 and show the not-found page instead. // Wait until the report exists locally and is no longer optimistic before fetching it. - if (isPendingCreationFromRoute && (!reportOnyx?.reportID || reportMetadata.isOptimisticReport)) { + if (isPendingCreationFromRoute && (!reportOnyx?.reportID || isPendingReportCreation)) { return; } @@ -388,13 +390,14 @@ function ReportFetchHandler() { // isPendingCreation is only needed before the report exists on the server. Clear it from the route params once it // does, so a copied URL, restored navigation state, or reload doesn't carry the stale flag and skip - // fetching again later. + // fetching again later. A support-ticket request is lost on reload, so its RAM-only guard also resets + // and lets OpenReport discover whether the server created the ticket. useEffect(() => { - if (!isPendingCreationFromRoute || !reportOnyx?.reportID || reportMetadata.isOptimisticReport) { + if (!isPendingCreationFromRoute || !reportOnyx?.reportID || isPendingReportCreation) { return; } navigation.setParams({isPendingCreation: undefined}); - }, [isPendingCreationFromRoute, reportOnyx?.reportID, reportMetadata.isOptimisticReport, navigation]); + }, [isPendingCreationFromRoute, reportOnyx?.reportID, isPendingReportCreation, navigation]); // A restored or shared route can carry isPendingCreation with no submit behind it. The owning submit writes the // report row within the dismiss transition budget (about 2s) after this screen gains focus, so a focused screen diff --git a/src/types/onyx/ReportLoadingState.ts b/src/types/onyx/ReportLoadingState.ts index dea1e5985bc9..bf033bb8ca71 100644 --- a/src/types/onyx/ReportLoadingState.ts +++ b/src/types/onyx/ReportLoadingState.ts @@ -18,6 +18,9 @@ type ReportLoadingState = { /** Flag to check if the initial report actions fetch is in flight */ isLoadingInitialReportActions?: boolean; + /** Whether a support-ticket creation request is in flight in this session */ + isCreatingSupportTicket?: boolean; + /** Whether participants private notes are being currently loaded */ isLoadingPrivateNotes?: boolean; diff --git a/tests/actions/SupportTicketTest.ts b/tests/actions/SupportTicketTest.ts index 9115ebced154..1796bdc2bc42 100644 --- a/tests/actions/SupportTicketTest.ts +++ b/tests/actions/SupportTicketTest.ts @@ -65,8 +65,8 @@ describe('actions/Report', () => { optimisticData: [ { onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, - value: {isOptimisticReport: true}, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: true}, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -77,8 +77,8 @@ describe('actions/Report', () => { successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, - value: {isOptimisticReport: false}, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + value: {isCreatingSupportTicket: false}, }, ], failureData: [ @@ -89,7 +89,7 @@ describe('actions/Report', () => { }, { onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, value: null, }, ], @@ -107,13 +107,13 @@ describe('actions/Report', () => { it('marks a new ticket optimistic before publishing its report ID', async () => { // Given a screen observing a ticket that has not been created on the server - let isOptimisticReport = false; + let isCreatingSupportTicket = false; const reportStates: boolean[] = []; // Observe both keys independently, as the report screen does while creation is pending. - const metadataConnection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, - callback: (metadata) => { - isOptimisticReport = !!metadata?.isOptimisticReport; + const loadingStateConnection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, + callback: (loadingState) => { + isCreatingSupportTicket = !!loadingState?.isCreatingSupportTicket; }, }); const reportConnection = Onyx.connectWithoutView({ @@ -122,7 +122,7 @@ describe('actions/Report', () => { if (!report?.reportID) { return; } - reportStates.push(isOptimisticReport); + reportStates.push(isCreatingSupportTicket); }, }); await waitForBatchedUpdates(); @@ -139,7 +139,7 @@ describe('actions/Report', () => { expect(reportStates).toEqual([true]); } finally { Onyx.disconnect(reportConnection); - Onyx.disconnect(metadataConnection); + Onyx.disconnect(loadingStateConnection); } }); @@ -150,7 +150,7 @@ describe('actions/Report', () => { reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET, }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`, {isOptimisticReport: true}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`, {isCreatingSupportTicket: true}); mockMakeRequestWithSideEffects.mockRejectedValueOnce(networkError); // When the customer asks to talk to a human @@ -159,33 +159,49 @@ describe('actions/Report', () => { // Then the App removes the pending report and preserves the request failure await expect(request).rejects.toBe(networkError); await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + }); + + it('keeps the report and releases its fetch guard after creation succeeds', async () => { + // Given a new ticket whose server response applies the API's success updates + mockMakeRequestWithSideEffects.mockImplementationOnce(async (_command, _parameters, onyxData) => { + await Onyx.update(onyxData?.optimisticData ?? []); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toEqual({isCreatingSupportTicket: true}); + await Onyx.update(onyxData?.successData ?? []); + return {jsonCode: CONST.JSON_CODE.SUCCESS}; + }); + + // When the server accepts the creation request + await openSupportTicket(); + + // Then the ticket remains available and can be fetched + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toEqual({isCreatingSupportTicket: false}); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toEqual({ + reportID: newSupportTicketReportID, + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + }); await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`)).resolves.toBeUndefined(); }); - it.each([CONST.JSON_CODE.SUCCESS, CONST.JSON_CODE.EXP_ERROR])('settles the optimistic ticket after server response %s', async (jsonCode) => { - // Given a new ticket whose creation response applies the API's success or failure updates + it('removes the pending report and loading state when the server rejects creation', async () => { + // Given a new ticket whose server response applies the API's failure updates mockMakeRequestWithSideEffects.mockImplementationOnce(async (_command, _parameters, onyxData) => { await Onyx.update(onyxData?.optimisticData ?? []); await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toEqual({ reportID: newSupportTicketReportID, type: CONST.REPORT.TYPE.SUPPORT_TICKET, }); - await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`)).resolves.toEqual({isOptimisticReport: true}); - await Onyx.update((jsonCode === CONST.JSON_CODE.SUCCESS ? onyxData?.successData : onyxData?.failureData) ?? []); - return {jsonCode}; + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toEqual({isCreatingSupportTicket: true}); + await Onyx.update(onyxData?.failureData ?? []); + return {jsonCode: CONST.JSON_CODE.EXP_ERROR}; }); - // When the server accepts or rejects the creation request + // When the server rejects the creation request await openSupportTicket(); - // Then a successful ticket can be fetched, while a rejected ticket leaves no optimistic data behind - if (jsonCode === CONST.JSON_CODE.SUCCESS) { - await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`)).resolves.toEqual({isOptimisticReport: false}); - await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toMatchObject({reportID: newSupportTicketReportID}); - } else { - await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toBeUndefined(); - await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${newSupportTicketReportID}`)).resolves.toBeUndefined(); - } + // Then neither the pending ticket nor its fetch guard remains + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newSupportTicketReportID}`)).resolves.toBeUndefined(); + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${newSupportTicketReportID}`)).resolves.toBeUndefined(); }); it('does not update Onyx when reopening a support ticket fails', async () => { diff --git a/tests/unit/ReportFetchHandlerTest.tsx b/tests/unit/ReportFetchHandlerTest.tsx index 74319fc7176a..2bcb4a55d360 100644 --- a/tests/unit/ReportFetchHandlerTest.tsx +++ b/tests/unit/ReportFetchHandlerTest.tsx @@ -125,7 +125,7 @@ describe('ReportFetchHandler', () => { mockIsOffline = isOffline; setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT_ID}`, {isOptimisticReport: true}); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isCreatingSupportTicket: true}); await waitForBatchedUpdates(); // When the customer opens the pending ticket @@ -141,7 +141,7 @@ describe('ReportFetchHandler', () => { // Given a mounted support ticket waiting for creation setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT_ID}`, {isOptimisticReport: true}); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isCreatingSupportTicket: true}); await waitForBatchedUpdates(); const {rerender} = renderHandler(); await waitForBatchedUpdates(); @@ -149,7 +149,7 @@ describe('ReportFetchHandler', () => { expect(mockSetParams).not.toHaveBeenCalledWith({isPendingCreation: undefined}); // When creation succeeds, the pending route can be released - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT_ID}`, {isOptimisticReport: false}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isCreatingSupportTicket: false}); await waitForBatchedUpdates(); expect(mockSetParams).toHaveBeenCalledWith({isPendingCreation: undefined}); setRouteParams({reportID: REPORT_ID}); @@ -160,6 +160,33 @@ describe('ReportFetchHandler', () => { expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID: REPORT_ID})); }); + it.each([false, true])('recovers a pending support ticket after reloading while offline is %s', async (isOffline) => { + // Given a pending ticket whose creation request belongs to the current app session + mockIsOffline = isOffline; + setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.SUPPORT_TICKET}); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, {isCreatingSupportTicket: true}); + await waitForBatchedUpdates(); + const {unmount} = renderHandler(); + await waitForBatchedUpdates(); + expect(mockOpenReport).not.toHaveBeenCalled(); + + // When the app reloads, the report and URL survive but RAM-only state and the in-flight request do not + unmount(); + await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, null); + await waitForBatchedUpdates(); + const {rerender} = renderHandler(); + await waitForBatchedUpdates(); + + // Then the stale route is released and the report can resolve to server content or not-found once online + expect(mockSetParams).toHaveBeenCalledWith({isPendingCreation: undefined}); + mockIsOffline = false; + setRouteParams({reportID: REPORT_ID}); + rerender(); + await waitForBatchedUpdates(); + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID: REPORT_ID})); + }); + it('expires a stale isPendingCreation flag when the focused screen never receives a report row', async () => { // Given a restored route that still carries isPendingCreation but has no submit writing the report setRouteParams({reportID: REPORT_ID, isPendingCreation: 'true'}); From e3092294dfdaac54574f07f7655e39dc4e3536ca Mon Sep 17 00:00:00 2001 From: Francois Laithier Date: Wed, 14 Oct 2026 15:56:38 -0700 Subject: [PATCH 719/781] test(search): return Onyx metadata from SearchColumnsPage useOnyx mock usePolicyForMovingExpenses now reads the load status that useOnyx returns for the policy collection, and passes it to isLoadingOnyxValue. The SearchColumnsPage test mocked useOnyx to return only the value, so the metadata was undefined and the test crashed on main with "Cannot read properties of undefined (reading 'status')". Return a loaded status from the mock, as the real hook does. --- tests/ui/SearchColumnsPageTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/SearchColumnsPageTest.tsx b/tests/ui/SearchColumnsPageTest.tsx index 5c8b96f9eaea..d0719828f7a2 100644 --- a/tests/ui/SearchColumnsPageTest.tsx +++ b/tests/ui/SearchColumnsPageTest.tsx @@ -38,7 +38,7 @@ describe('SearchColumnsPage', () => { } const form = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); - (jest.mocked(useOnyx) as jest.Mock).mockImplementation((key: string) => [key === ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM ? form : undefined]); + (jest.mocked(useOnyx) as jest.Mock).mockImplementation((key: string) => [key === ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM ? form : undefined, {status: 'loaded'}]); mockGetRootState.mockReturnValue({ routes: [{name: NAVIGATORS.TAB_NAVIGATOR, params: {screen: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, params: {screen: SCREENS.SEARCH.ROOT, params: {q: input}}}}], }); From 91660886453e5bc13a64534c148a1f452092b6e2 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Wed, 14 Oct 2026 15:57:18 -0700 Subject: [PATCH 720/781] fix(support): ignore persisted pending metadata --- src/pages/inbox/ReportFetchHandler.tsx | 2 +- tests/unit/ReportFetchHandlerTest.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index ff189b4b44e0..ff7b6840976e 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -130,7 +130,7 @@ function ReportFetchHandler() { const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportOnyx?.chatReportID}`); const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`); const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); - const isPendingReportCreation = !!reportMetadata.isOptimisticReport || !!reportLoadingState.isCreatingSupportTicket; + const isPendingReportCreation = !!reportLoadingState.isCreatingSupportTicket; const isReportActionsLoaded = useIsReportActionsLoaded(reportIDFromRoute); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); diff --git a/tests/unit/ReportFetchHandlerTest.tsx b/tests/unit/ReportFetchHandlerTest.tsx index 2bcb4a55d360..7b96e63efda2 100644 --- a/tests/unit/ReportFetchHandlerTest.tsx +++ b/tests/unit/ReportFetchHandlerTest.tsx @@ -171,8 +171,10 @@ describe('ReportFetchHandler', () => { await waitForBatchedUpdates(); expect(mockOpenReport).not.toHaveBeenCalled(); - // When the app reloads, the report and URL survive but RAM-only state and the in-flight request do not + // When the app reloads, the report, URL, and stale persisted metadata survive. + // RAM-only state and the in-flight request do not. unmount(); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${REPORT_ID}`, {isOptimisticReport: true}); await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, null); await waitForBatchedUpdates(); const {rerender} = renderHandler(); From d6846a01b518e59bcc14219a8898229b20a36fa5 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 23:22:33 +0000 Subject: [PATCH 721/781] Update version to 9.5.9-2 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index d46e331cc78c..e99091dd6d37 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050901 - versionName "9.5.9-1" + versionCode 1009050902 + versionName "9.5.9-2" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 59b836c9b220..3c347b9bba57 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.9.1 + 9.5.9.2 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 014041ab93a8..35a77f177610 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.9 CFBundleVersion - 9.5.9.1 + 9.5.9.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index df49eadc0625..f29d1f38f0d4 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.9 CFBundleVersion - 9.5.9.1 + 9.5.9.2 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 5e9b46731c13..f03139f5bbcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.9-1", + "version": "9.5.9-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.9-1", + "version": "9.5.9-2", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 2de9b07868c9..6718e82c6a75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.9-1", + "version": "9.5.9-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 8f70fe64834b4ec76a48b58d64fea4990d61b72b Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 14 Oct 2026 23:22:33 +0000 Subject: [PATCH 722/781] Update Mobile-Expensify submodule version to 9.5.9-2 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index ef32984fc009..9c0c13f6df15 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit ef32984fc0091a867c96e488362daf78acfc3fa9 +Subproject commit 9c0c13f6df15ffffc515dd4cfae482064241fbef From 1489fb930faaeaf89878c67ea6a9931eaea24cf3 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 00:08:46 +0000 Subject: [PATCH 723/781] Update version to 9.5.9-3 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index e99091dd6d37..aa9cf850f832 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050902 - versionName "9.5.9-2" + versionCode 1009050903 + versionName "9.5.9-3" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 3c347b9bba57..fefc08e41b3a 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.9.2 + 9.5.9.3 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 35a77f177610..288dbb799066 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.9 CFBundleVersion - 9.5.9.2 + 9.5.9.3 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index f29d1f38f0d4..fcc836770bb3 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.9 CFBundleVersion - 9.5.9.2 + 9.5.9.3 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index f03139f5bbcc..dadafe87d443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.9-2", + "version": "9.5.9-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.9-2", + "version": "9.5.9-3", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 6718e82c6a75..7ff3cc7e5749 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.9-2", + "version": "9.5.9-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 192cf1e415b663ad70b11f68416e84918549f2d3 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 00:08:46 +0000 Subject: [PATCH 724/781] Update Mobile-Expensify submodule version to 9.5.9-3 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 9c0c13f6df15..b4f818310a60 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 9c0c13f6df15ffffc515dd4cfae482064241fbef +Subproject commit b4f818310a60e4e3d7b1057c8f0e75f413a83f1b From 7c13b8e5009ef48f206f7ba4321b80ab3d4376d1 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 00:10:50 +0000 Subject: [PATCH 725/781] Update version to 9.5.9-4 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index aa9cf850f832..8a24c18c5e2a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050903 - versionName "9.5.9-3" + versionCode 1009050904 + versionName "9.5.9-4" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index fefc08e41b3a..43ceca2dc4d6 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.9.3 + 9.5.9.4 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 288dbb799066..20d42fe8fcd9 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.9 CFBundleVersion - 9.5.9.3 + 9.5.9.4 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index fcc836770bb3..29f934ce340a 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.9 CFBundleVersion - 9.5.9.3 + 9.5.9.4 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index dadafe87d443..aeae67497526 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.9-3", + "version": "9.5.9-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.9-3", + "version": "9.5.9-4", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 7ff3cc7e5749..3c8b2785ac90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.9-3", + "version": "9.5.9-4", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 978256f76c52166632cad31f12447ec4d3035740 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 00:10:50 +0000 Subject: [PATCH 726/781] Update Mobile-Expensify submodule version to 9.5.9-4 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index b4f818310a60..595f5c8f516c 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit b4f818310a60e4e3d7b1057c8f0e75f413a83f1b +Subproject commit 595f5c8f516c7d28f8c412e2658bfd489b0cbba7 From 55d7333612f0fd0c1acda516fe25254496194d60 Mon Sep 17 00:00:00 2001 From: Yuwen Memon Date: Wed, 14 Oct 2026 17:40:13 -0700 Subject: [PATCH 727/781] docs(netsuite): apply NS0068 article review and accuracy fixes --- .../Export-Errors/NS0068-Export-Error.md | 95 ++++++++----------- .../Export-Errors/NS0068-Export-Error.md | 84 +++++++--------- 2 files changed, 75 insertions(+), 104 deletions(-) diff --git a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md index d8a66b720cf8..754345740a96 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md +++ b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md @@ -1,8 +1,8 @@ --- title: NS0068 Export Error in NetSuite Integration description: Learn how to fix the NS0068 export error in NetSuite when Expensify can't set the Created From field (custbody_createdfrom_expensify) during export. -keywords: NS0068, NetSuite Created From field not visible, custbody_createdfrom_expensify, permission to set value Created From, Transaction Body Fields NetSuite, NetSuite transaction form visibility, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite export error, Workspace Admin -internalScope: Audience is Workspace Admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the preferred form, not being editable by the Expensify role, or missing from NetSuite. Does not cover other NetSuite error codes. +keywords: NS0068, NetSuite Created From field not visible, custbody_createdfrom_expensify, permission to set value Created From, Transaction Body Fields NetSuite, NetSuite transaction form visibility, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite export error, workspace admin +internalScope: Audience is workspace admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the export form, not being editable by the Expensify role, or missing from NetSuite. Does not cover other NetSuite error codes. --- @@ -15,93 +15,76 @@ NS0068: NetSuite won't let Expensify set the 'Created From' field (custbody_crea This means NetSuite is blocking Expensify from setting the **Created From** field during export. -The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. It is added to NetSuite by the **Expensify Connect bundle**. +The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. The **Expensify Connect bundle** adds it to NetSuite. --- -## Why the NS0068 Export Error Happens in NetSuite +## Why the NS0068 export error happens in NetSuite The NS0068 error occurs when: -- The **Created From** field does not apply to the record type you're exporting to (for example, vendor bills, expense reports, or journal entries). +- The **Created From** field does not apply to the record type you're exporting to (vendor bills, expense reports, or journal entries). - The **Created From** field is hidden on the preferred export form. - The role used by the Expensify connection can't edit the **Created From** field. - The **Created From** field is missing because the **Expensify Connect bundle** is not installed or is out of date. -- Expensify Card transactions are exported as journal entries and the Journal Entry form does not show the Created From field. +- Expensify Card transactions are exported as journal entries and the journal entry form does not show the Created From field. Expensify must be able to write to the Created From field during export. --- -## How to Fix the NS0068 Export Error +## How to check the Created From field settings in NetSuite -### Check the Created From Field Settings +1. Log in to **NetSuite** as an administrator. +2. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields**. +3. Open the field with the ID **custbody_createdfrom_expensify**. +4. On the **Applies To** tab, confirm the option for your export type is selected: **Purchase** for vendor bills, **Expense Report** for expense reports, or **Journal** for journal entries. +5. On the **Access** tab, confirm **Default Access Level** is **Edit**. If the **Role** list includes the role used by the Expensify connection, such as **Expensify Integration**, confirm its access level is also **Edit**. +6. Select **Save**. -1. Log in to **NetSuite** as an Administrator. -2. Go to **Customization**. -3. Select **Lists, Records, & Fields**. -4. Click **Transaction Body Fields**. -5. Open the field with the ID **custbody_createdfrom_expensify**. -6. On the **Applies To** tab, confirm the field applies to the record type you're exporting to. -7. On the **Access** tab, confirm the role used by the Expensify connection has **Edit** access to the field. -8. Save the field. - -If you can't find **custbody_createdfrom_expensify**, update or reinstall the **Expensify Connect bundle** in NetSuite before continuing. +If you can't find **custbody_createdfrom_expensify**, install or update the Expensify Connect bundle in NetSuite before continuing. [Learn how to install the Expensify Connect bundle in NetSuite](/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite). --- -### If Exporting Vendor Bills or Standard Transactions +## How to show the Created From field on vendor bill and expense report forms + +1. Log in to **NetSuite** as an administrator. +2. Go to **Customization > Forms > Transaction Forms**. +3. Select **Edit** next to the form marked as **Preferred** for the export type. If you set a custom form ID for this export type in New Expensify, edit that form instead. +4. Go to **Screen Fields > Main**. +5. Confirm the **Created From** field is marked as **Show**. +6. Select **Save**. -1. Log in to **NetSuite** as an Administrator. -2. Go to **Customization**. -3. Select **Forms**. -4. Click **Transaction Forms**. -5. Click **Edit** next to the form marked as **Preferred** for the export type. -6. Locate the **Created From** field. -7. Ensure the field is marked as **Show**. -8. Save the form. +--- -Then in Expensify: +## How to show the Created From field on Expensify Card journal entry forms -1. Go to **Settings**. -2. Select **Workspaces**. -3. Select your Workspace. -4. Click **Accounting**. -5. Click **Sync**. -6. Retry exporting the report. +1. Log in to **NetSuite** as an administrator. +2. Confirm **custbody_createdfrom_expensify** applies to journal entries, using the steps in **How to check the Created From field settings in NetSuite** above. +3. Go to **Customization > Forms > Transaction Forms**. +4. Select **Edit** next to the preferred **Journal Entry** form. +5. Go to **Screen Fields > Main**. +6. Confirm the **Created From** field is marked as **Show**. +7. Select **Save**. --- -### If Exporting Expensify Card Transactions as Journal Entries - -1. Log in to **NetSuite**. -2. Confirm **custbody_createdfrom_expensify** applies to journal entries, using the steps in **Check the Created From Field Settings** above. -3. Go to **Customization**. -4. Select **Forms**. -5. Click **Transaction Forms**. -6. Click **Edit** next to the preferred **Journal Entry** form. -7. Go to **Screen Fields**. -8. Open the **Main** tab. -9. Ensure the **Created From** field is marked as **Show**. -10. Save the form. +## How to sync NetSuite and retry the export -Then in Expensify: +After updating NetSuite: -1. Go to **Settings**. -2. Select **Workspaces**. -3. Select your Workspace. -4. Click **Accounting**. -5. Click **Sync**. -6. Retry exporting the report. +1. Go to **Settings > Workspaces > [workspace name] > Accounting**. +2. Select **Sync Now**. +3. Retry exporting the report. --- # FAQ -## Does NS0068 Mean My Integration Is Broken? +## Does NS0068 mean my integration is broken? No. This error is caused by the Created From field settings, form visibility, or role access in NetSuite. -## Do I Need to Reconnect NetSuite? +## Do I need to reconnect NetSuite? -No. Updating the field and form settings and running **Sync** is typically sufficient. If the field is missing, update or reinstall the Expensify Connect bundle first. +No. Updating the field and form settings and selecting **Sync Now** is typically sufficient. If the field is missing, install or update the Expensify Connect bundle first. diff --git a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md index 4e5540cf2d8c..6f19e7d253bb 100644 --- a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md +++ b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md @@ -1,8 +1,8 @@ --- title: NS0068 Export Error in NetSuite Integration description: Learn what the NS0068 export error means and how to configure the Created From field (custbody_createdfrom_expensify) in NetSuite so Expensify can set it during export. -keywords: NS0068, NetSuite Created From field error, custbody_createdfrom_expensify, permission to set Created From NetSuite, Transaction Body Fields NetSuite, transaction form visibility NetSuite, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite integration, Workspace Admin -internalScope: Audience is Workspace Admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the preferred form, not being editable by the Expensify role, or missing from NetSuite. Does not cover token configuration issues. +keywords: NS0068, NetSuite Created From field error, custbody_createdfrom_expensify, permission to set Created From NetSuite, Transaction Body Fields NetSuite, transaction form visibility NetSuite, custom form ID NetSuite, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite integration, workspace admin +internalScope: Audience is workspace admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the export form, not being editable by the Expensify role, or missing from NetSuite. Does not cover token configuration issues. --- @@ -15,16 +15,16 @@ NS0068 Export Error: NetSuite won't let Expensify set the 'Created From' field ( This means NetSuite is blocking Expensify from setting the **Created From** field during export. -The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. It is added to NetSuite by the **Expensify Connect bundle**. +The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. The **Expensify Connect bundle** adds it to NetSuite. --- -## Why the NS0068 Export Error Happens in NetSuite +## Why the NS0068 export error happens in NetSuite The NS0068 error typically occurs when: -- The **Created From** field does not apply to the record type you're exporting to (for example, vendor bills, expense reports, or journal entries). -- The **Created From** field is hidden on the transaction form marked as **Preferred** for your export type. +- The **Created From** field does not apply to the record type you're exporting to (vendor bills, expense reports, or journal entries). +- The **Created From** field is hidden on the transaction form Expensify uses for your export type. - The role used by the Expensify connection can't edit the **Created From** field. - The **Created From** field is missing from NetSuite because the **Expensify Connect bundle** is not installed or is out of date. @@ -32,95 +32,83 @@ If any of these apply, NetSuite prevents the integration from setting the field --- -## How to Fix the NS0068 Export Error +## How to fix the NS0068 export error Follow the steps below to check the field setup, form visibility, and role access in NetSuite. --- -## Check the Created From Field Settings in NetSuite +## How to check the Created From field settings in NetSuite 1. Log in to NetSuite as an administrator. 2. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields**. 3. Open the field with the ID **custbody_createdfrom_expensify**. -4. On the **Applies To** tab, confirm the field applies to the record type you're exporting to. -5. On the **Access** tab, confirm the role used by the Expensify connection has **Edit** access to the field. -6. Click **Save**. +4. On the **Applies To** tab, confirm the option for your export type is selected: **Purchase** for vendor bills, **Expense Report** for expense reports, or **Journal** for journal entries. +5. On the **Access** tab, confirm **Default Access Level** is **Edit**. If the **Role** list includes the role used by the Expensify connection, such as **Expensify Integration**, confirm its access level is also **Edit**. +6. Select **Save**. -If you can't find **custbody_createdfrom_expensify**, [update the Expensify Connect bundle](https://help.expensify.com/articles/new-expensify/connections/netsuite/Update-Expensify-Connect-Bundle-in-NetSuite), or reinstall it, before continuing. +If you can't find **custbody_createdfrom_expensify**, update or reinstall the Expensify Connect bundle before continuing. [Learn how to update the Expensify Connect bundle in NetSuite](/articles/new-expensify/connections/netsuite/Update-Expensify-Connect-Bundle-in-NetSuite). --- -## Make the Created From Field Visible on the Preferred Form +## How to make the Created From field visible on the export form -1. Go to **Customization > Forms > Transaction Forms**. -2. Locate the transaction form marked as **Preferred** for your export type. -3. Click **Edit**. -4. Locate the **Created From** field. -5. Ensure the field is marked as **Show**. -6. Click **Save**. +Expensify exports with the transaction form marked as **Preferred** in NetSuite. If you set a custom form under **Workspaces > [workspace name] > Accounting > Advanced > Custom form ID**, Expensify uses that form instead. -Confirm the field is visible on the preferred form before proceeding. +1. Go to **Customization > Forms > Transaction Forms**. +2. Locate the transaction form Expensify uses for your export type. +3. Select **Edit**. +4. Go to **Screen Fields > Main**. +5. Confirm the **Created From** field is marked as **Show**. +6. Select **Save**. --- -## Sync the Workspace in Expensify +## How to sync the workspace in Expensify After updating NetSuite: -On web: - -1. Go to the navigation tabs on the left and select **Workspaces**. -2. Select your Workspace. -3. Click **Accounting**. -4. Click the three-dot menu next to the NetSuite connection. -5. Click **Sync Now**. - -On mobile: - -1. Tap the navigation tabs on the bottom and select **Workspaces**. -2. Select your Workspace. -3. Tap **Accounting**. -4. Tap the three-dot menu next to the NetSuite connection. -5. Tap **Sync Now**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name] > Accounting**. +2. Select **More (⋮)** next to the NetSuite connection. +3. Select **Sync now**. --- -## Retry the Export +## How to retry the export 1. Open the report. 2. Retry exporting to NetSuite. -If the Created From field applies to your export type, is visible on the preferred form, and is editable by the Expensify role, the export should complete successfully. +If the Created From field applies to your export type, is visible on the export form, and is editable by the Expensify role, the export should complete successfully. --- -# Additional Steps for Expensify Card Journal Entries +## How to fix the NS0068 export error for Expensify Card journal entries If you are exporting **Expensify Card transactions as journal entries**, confirm the Created From field is set up for journal entries. 1. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields** and open **custbody_createdfrom_expensify**. -2. On the **Applies To** tab, confirm the field applies to journal entries, then click **Save**. +2. On the **Applies To** tab, confirm **Journal** is selected, then select **Save**. 3. Go to **Customization > Forms > Transaction Forms**. -4. Click **Edit** next to the preferred journal entry form. +4. Select **Edit** next to the preferred journal entry form. 5. Go to **Screen Fields > Main**. 6. Confirm **Created From** is marked as **Show**. -7. Click **Save**. +7. Select **Save**. -Then return to the Workspace and select **Sync Now** before retrying the export. +Then sync the workspace in Expensify before retrying the export. --- # FAQ -## Does the NS0068 Export Error Affect All Export Types? +## Does the NS0068 export error affect all export types? -It affects any export type where the Created From field doesn't apply to the record, is hidden on the preferred form, or isn't editable by the Expensify role. +It affects any export type where the Created From field doesn't apply to the record, is hidden on the export form, or isn't editable by the Expensify role. -## Do I Need NetSuite Admin Access to Fix the NS0068 Export Error? +## Do I need NetSuite admin access to fix the NS0068 export error? Yes. Updating custom field settings and transaction form visibility requires NetSuite administrator permissions. -## Do I Need to Reconnect the Integration? +## Do I need to reconnect the integration? -No. Updating the Created From field settings and selecting **Sync Now** is typically sufficient. If the field is missing, update or reinstall the Expensify Connect bundle first. +No. Updating the Created From field settings and selecting **Sync now** is typically sufficient. If the field is missing, update or reinstall the Expensify Connect bundle first. From aa8c3e4e1e839965a1be23f92f396e9ce8852546 Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Thu, 15 Oct 2026 03:51:31 +0000 Subject: [PATCH 728/781] Move DOM focus to the search input when ArrowUp leaves the invite list, and clarify the TextInput onPress JSDoc Co-authored-by: huult --- .../BaseSelectionListWithSections.tsx | 6 +++ .../SelectionList/components/TextInput.tsx | 2 +- .../hooks/useSelectionListKeyboardFocus.ts | 9 +++++ .../useSelectionListKeyboardFocus.test.ts | 40 +++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 6c993d361991..e43ca335501f 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -125,10 +125,16 @@ function BaseSelectionListWithSectionsImpl({ announceProgrammaticScroll: () => listRef.current?.announceProgrammaticScroll(), setShouldDisableHoverStyle, shouldAllowArrowUpToSearchInput, + onArrowUpToSearchInput: focusSearchInputFromList, }); const {innerTextInputRef, isTextInputFocusedRef, focusTextInput, textInputKeyPress} = useSelectionListTextInput(setHasKeyBeenPressed); + // A Tab-focused row keeps DOM focus after ArrowUp clears the focused index, so move it back to the search input + function focusSearchInputFromList() { + focusTextInput(); + } + const getFocusedItem = useCallback((): ListItem | undefined => { if (focusedIndex < 0 || focusedIndex >= flattenedData.length) { return; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index d6cf0864a713..591820f50008 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -38,7 +38,7 @@ type TextInputProps = { /** Callback function called when the text input is submitted */ onSubmit?: () => void; - /** Callback function called when the text input is pressed */ + /** Lets a list clear its focused row on press, so plain Enter in the search input reaches the confirm button instead of toggling a row */ onPress?: () => void; onKeyPress?: (event: TextInputKeyPressEvent) => void; diff --git a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts index 62b8561cc370..6220fba212bd 100644 --- a/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts +++ b/src/components/SelectionList/hooks/useSelectionListKeyboardFocus.ts @@ -23,6 +23,9 @@ type UseSelectionListKeyboardFocusParams = { /** Whether ArrowUp from the first row moves to index -1 (the search input) instead of wrapping to the last row */ shouldAllowArrowUpToSearchInput?: boolean; + + /** Called when ArrowUp leaves the first row for the search input, so DOM focus can follow */ + onArrowUpToSearchInput?: () => void; }; type UseSelectionListKeyboardFocusResult = { @@ -46,6 +49,7 @@ function useSelectionListKeyboardFocus({ announceProgrammaticScroll, setShouldDisableHoverStyle, shouldAllowArrowUpToSearchInput = false, + onArrowUpToSearchInput, }: UseSelectionListKeyboardFocusParams): UseSelectionListKeyboardFocusResult { const hasKeyBeenPressed = useRef(false); const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false); @@ -76,6 +80,11 @@ function useSelectionListKeyboardFocus({ disabledIndexes, isActive, onFocusedIndexChange: (index, shouldScrollHint) => { + // Only an arrow-key move sets shouldScrollHint, so a press on the search input (which already holds focus) is skipped + if (shouldAllowArrowUpToSearchInput && shouldScrollHint && index < 0) { + onArrowUpToSearchInput?.(); + } + if (!shouldScrollHint || !shouldScrollToFocusedIndex) { return; } diff --git a/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts b/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts index 79c1feab1d42..87b604d3f277 100644 --- a/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts +++ b/tests/unit/components/SelectionList/useSelectionListKeyboardFocus.test.ts @@ -29,6 +29,8 @@ type Overrides = { shouldScrollToFocusedIndex?: boolean; shouldDebounceScrolling?: boolean; initialFocusedIndex?: number; + shouldAllowArrowUpToSearchInput?: boolean; + onArrowUpToSearchInput?: () => void; }; function renderKeyboardFocus(overrides: Overrides = {}) { @@ -100,6 +102,44 @@ describe('useSelectionListKeyboardFocus', () => { }); }); + describe('onArrowUpToSearchInput', () => { + it('is called when ArrowUp moves the focused index to the search input', () => { + // Given a list that lets ArrowUp leave the first row for the search input + const onArrowUpToSearchInput = jest.fn(); + renderKeyboardFocus({shouldAllowArrowUpToSearchInput: true, onArrowUpToSearchInput}); + + // When an arrow-key move sets the focused index to -1 + capturedConfig.onFocusedIndexChange?.(-1, true); + + // Then the list is told to move DOM focus to the search input, since a Tab-focused row would otherwise keep it + expect(onArrowUpToSearchInput).toHaveBeenCalledTimes(1); + }); + + it('is not called when the focused index is cleared without an arrow key', () => { + // Given a list that lets ArrowUp leave the first row for the search input + const onArrowUpToSearchInput = jest.fn(); + renderKeyboardFocus({shouldAllowArrowUpToSearchInput: true, onArrowUpToSearchInput}); + + // When the focused index is cleared externally, for example by pressing the search input + capturedConfig.onFocusedIndexChange?.(-1, false); + + // Then focus is left alone, because the search input already holds it + expect(onArrowUpToSearchInput).not.toHaveBeenCalled(); + }); + + it('is not called when the list does not opt in', () => { + // Given a list that keeps the default ArrowUp behavior + const onArrowUpToSearchInput = jest.fn(); + renderKeyboardFocus({onArrowUpToSearchInput}); + + // When an arrow-key move sets the focused index to -1 + capturedConfig.onFocusedIndexChange?.(-1, true); + + // Then DOM focus is not moved, preserving existing behavior for other lists + expect(onArrowUpToSearchInput).not.toHaveBeenCalled(); + }); + }); + describe('onArrowUpDownCallback', () => { it('disables hover styling and announces a programmatic scroll', () => { const {setShouldDisableHoverStyle, announceProgrammaticScroll} = renderKeyboardFocus(); From 9ee12e103640fac711c98d5e0a768d7ef5d39200 Mon Sep 17 00:00:00 2001 From: "nkdengineer (via MelvinBot)" Date: Thu, 15 Oct 2026 04:55:22 +0000 Subject: [PATCH 729/781] Move PolicyUtils approval and payment helpers into approvals.ts and payments.ts Phase 7 of the PolicyUtils split. Pure move: no renames, signature changes, or behavior changes. Callers stay on @libs/PolicyUtils through export *. Co-authored-by: nkdengineer --- src/libs/PolicyUtils/approvals.ts | 454 +++++++++++++++ src/libs/PolicyUtils/index.ts | 547 +------------------ src/libs/PolicyUtils/payments.ts | 111 ++++ tests/actions/IOU/SplitReportTotalsTest.ts | 2 + tests/unit/ReportSecondaryActionUtilsTest.ts | 2 + 5 files changed, 576 insertions(+), 540 deletions(-) create mode 100644 src/libs/PolicyUtils/approvals.ts create mode 100644 src/libs/PolicyUtils/payments.ts diff --git a/src/libs/PolicyUtils/approvals.ts b/src/libs/PolicyUtils/approvals.ts new file mode 100644 index 000000000000..860ef968e67f --- /dev/null +++ b/src/libs/PolicyUtils/approvals.ts @@ -0,0 +1,454 @@ +/** + * Approval mode, the default approver, approval workflow rules, and who a report submits or forwards to. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; + +import {getCategoryApproverRule} from '@libs/CategoryUtils'; +import {getHRAdvancedModeFinalApprover} from '@libs/merge/HRUtils'; +import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; +import {isApprovalWorkflowRule, isRuleFilterComparison} from '@libs/RuleUtils'; +import {getAllSortedTransactions, getCategory, getTag} from '@libs/TransactionUtils'; +import {isValidAccountRoute} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Policy, Report} from '@src/types/onyx'; +import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; +import type {ApprovalRule} from '@src/types/onyx/Policy'; +import type Rule from '@src/types/onyx/Rule'; +import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {isPolicyApprover} from './permissions'; +import {isGroupPolicy} from './policyType'; +import {getTagApproverRule} from './rules'; +import {isSubmitAndClose} from './workflows'; + +/** The report facts an approval workflow rule is matched against. */ +type ApprovalWorkflowContext = { + /** Email of the member who submitted the report. */ + submitterEmail: string; + + /** Email of the approver the report is currently with. Empty when the report has not been approved yet. */ + currentApproverEmail?: string; + + /** Total of the report, in the backend (cents) amount the rules' `amount` filters are written in. */ + reportTotal: number; +}; + +/** The outcome of matching a report against the policy's approval workflow rules. */ +type ApprovalWorkflowRuleMatch = { + /** Email the matched rule forwards the report to. Undefined when the rule finalizes the report instead. */ + forwardsTo?: string; +}; + +/** + * Returns true when the user is both a submitter and an approver, mirroring the Submit/Approve suggested-search eligibility in + * `getSuggestedSearchesVisibility` (SearchUIUtils): a submitter is a member of any group workspace, and an approver is a member of a + * group workspace with a non-optional approval flow whom `isPolicyApprover` recognizes (named approver or someone reports submit/forward to). + */ +function isSubmitterAndApprover(policies: OnyxCollection | null | undefined, currentUserEmail: string | undefined): boolean { + if (!policies || !currentUserEmail) { + return false; + } + let isSubmitter = false; + let isApprover = false; + for (const policy of Object.values(policies)) { + if (!policy) { + continue; + } + isSubmitter = isSubmitter || isGroupPolicy(policy); + if (!isApprover) { + const hasApprovalFlow = isGroupPolicy(policy) && !!policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; + isApprover = hasApprovalFlow && isPolicyApprover(policy, currentUserEmail); + } + if (isSubmitter && isApprover) { + return true; + } + } + return false; +} + +function isControlOnAdvancedApprovalMode(policy: OnyxInputOrEntry): boolean { + return policy?.type === CONST.POLICY.TYPE.CORPORATE && getApprovalWorkflow(policy) === CONST.POLICY.APPROVAL_MODE.ADVANCED; +} + +function getApprovalWorkflow(policy: OnyxEntry): ValueOf { + if (policy?.type === CONST.POLICY.TYPE.PERSONAL) { + return CONST.POLICY.APPROVAL_MODE.OPTIONAL; + } + + return policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.ADVANCED; +} + +function getDefaultApprover(policy: OnyxEntry): string { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return policy?.approver || policy?.owner || ''; +} + +/** + * Whether the policy has at least one custom approval workflow. A workflow is considered custom when either: + * - the default workflow was modified by changing its first approver or adding an "Approves to" user, or + * - a new workflow was created (a member submits to an approver other than the default approver). + */ +function hasCustomApprovalWorkflow(policy: OnyxEntry): boolean { + if (!policy) { + return false; + } + + const defaultApprover = getDefaultApprover(policy); + + // The default workflow's first approver was changed away from the workspace owner. + if (!!policy.approver && !!policy.owner && policy.approver !== policy.owner) { + return true; + } + + const employees = policy.employeeList ?? {}; + + // The default approver forwards approvals to someone, i.e. an "Approves to" user was added, + // either unconditionally (forwardsTo) or above an approval limit (overLimitForwardsTo). + const defaultApproverEmployee = employees[defaultApprover]; + if (!!defaultApprover && (!!defaultApproverEmployee?.forwardsTo || !!defaultApproverEmployee?.overLimitForwardsTo)) { + return true; + } + + // A new workflow exists when a member submits to an approver other than the default approver. + return Object.values(employees).some( + (employee) => employee?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!employee?.submitsTo && employee.submitsTo !== defaultApprover, + ); +} + +function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { + const categoryApprovers: string[] = []; + const tagApprovers: string[] = []; + const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); + + // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. + // Category approvers are prioritized, then tag approvers. + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); + const tag = getTag(transaction); + const category = getCategory(transaction); + const categoryApprover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + const tagApprover = getTagApproverRule(policy, tag)?.approver; + if (categoryApprover) { + categoryApprovers.push(categoryApprover); + } + + if (tagApprover) { + tagApprovers.push(tagApprover); + } + } + + // Return the unique approvers list + return [...new Set([...categoryApprovers, ...tagApprovers])]; +} + +function getFirstRuleApprover(approvalRules: ApprovalRule[], expenseReport: OnyxEntry, ownerLogin: string | undefined) { + // Pre-build a lookup map of { category: { value → approver }, tag: { value → approver } } + // from the policy's approval rules so that each transaction's category/tag can be resolved in O(1). + const rulesMap: Record<'category' | 'tag', Record> = { + category: {}, + tag: {}, + }; + + for (let i = 0; i < approvalRules.length; i++) { + const rule = approvalRules.at(i); + if (!rule) { + continue; + } + for (let j = 0; j < rule.applyWhen.length; j++) { + const applyWhen = rule.applyWhen.at(j); + if (applyWhen?.condition !== CONST.POLICY.RULE_CONDITIONS.MATCHES) { + continue; + } + if (applyWhen.field === CONST.POLICY.FIELDS.CATEGORY || applyWhen.field === CONST.POLICY.FIELDS.TAG) { + rulesMap[applyWhen.field][applyWhen.value] = rule.approver; + } + } + } + + if (isEmptyObject(rulesMap.category) && isEmptyObject(rulesMap.tag)) { + return ''; + } + + const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); + + if (!allReportTransactions.length) { + return ''; + } + + let firstCategoryApprover = ''; + let firstTagApprover = ''; + + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); + const category = getCategory(transaction); + const categoryApprover = rulesMap.category[category]; + + // Category approvers take strict priority over tag approvers. + // Break immediately on the first match so we don't keep scanning transactions unnecessarily. + if (categoryApprover && categoryApprover !== ownerLogin) { + firstCategoryApprover = categoryApprover; + break; + } + + // Only look for a tag approver if we haven't found one yet — no need to re-check on subsequent transactions. + if (!firstTagApprover) { + const tag = getTag(transaction); + const tagApprover = rulesMap.tag[tag]; + + if (tagApprover && tagApprover !== ownerLogin) { + firstTagApprover = tagApprover; + } + } + } + + return firstCategoryApprover || firstTagApprover; +} + +function matchesApprovalWorkflowEmailComparison(node: RuleFilterComparison, email: string | undefined): boolean { + const expectedEmails = (Array.isArray(node.right) ? node.right : [node.right]).map((value) => String(value).toLowerCase()); + const isMatch = !!email && expectedEmails.includes(email.toLowerCase()); + return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO ? !isMatch : isMatch; +} + +function matchesApprovalWorkflowAmountComparison(node: RuleFilterComparison, amount: number): boolean { + const expectedAmount = typeof node.right === 'number' ? node.right : Number(node.right); + if (Number.isNaN(expectedAmount)) { + return false; + } + + switch (node.operator) { + case CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO: + return amount === expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO: + return amount !== expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN: + return amount > expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO: + return amount >= expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN: + return amount < expectedAmount; + case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO: + return amount <= expectedAmount; + default: + return false; + } +} + +function evaluateApprovalWorkflowFilter(node: RuleFilterNode, context: ApprovalWorkflowContext): boolean { + if (!isRuleFilterComparison(node)) { + const left = evaluateApprovalWorkflowFilter(node.left, context); + const right = evaluateApprovalWorkflowFilter(node.right, context); + return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR ? left || right : left && right; + } + + switch (node.left) { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: + return matchesApprovalWorkflowEmailComparison(node, context.submitterEmail); + + // A report with no current approver has not been approved by anybody yet, so a rule based on who + // approved it last cannot apply. + case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: + return !!context.currentApproverEmail && matchesApprovalWorkflowEmailComparison(node, context.currentApproverEmail); + case CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT: + return matchesApprovalWorkflowAmountComparison(node, Math.abs(context.reportTotal)); + + default: + return false; + } +} + +/** + * True when every condition in the rule's filter tree is true for `context`. + */ +function evaluateApprovalWorkflowRule(rule: ApprovalWorkflowRule, context: ApprovalWorkflowContext): boolean { + return evaluateApprovalWorkflowFilter(rule.filters, context); +} + +/** + * Check the policy's approval workflow rules to determine where the report goes next. + */ +function getForwardsToFromRules(policy: OnyxEntry, context: ApprovalWorkflowContext, rules: OnyxCollection): ApprovalWorkflowRuleMatch | undefined { + if (!policy?.id || !context.submitterEmail) { + return undefined; + } + + const trigger = context.currentApproverEmail ? CONST.RULES.TRIGGERS.REPORT_APPROVE : CONST.RULES.TRIGGERS.REPORT_SUBMIT; + + // Sort by Onyx key so the rule picked stays the same across evaluations when more than one matches (which should not happen). + const ruleKeys = Object.keys(rules ?? {}).sort(); + for (const ruleKey of ruleKeys) { + const rule = rules?.[ruleKey]; + if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policy.id || rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + if (!isApprovalWorkflowRule(rule) || !Object.values(rule.triggers ?? {}).includes(trigger)) { + continue; + } + if (!evaluateApprovalWorkflowRule(rule, context)) { + continue; + } + + return {forwardsTo: Object.values(rule.actions ?? {}).find((action) => action.name === CONST.RULES.ACTIONS.FORWARD_TO)?.approver}; + } + + return undefined; +} + +function getManagerAccountEmail(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0): string { + const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal}, rules); + if (ruleMatch) { + return ruleMatch.forwardsTo ?? ''; + } + + const defaultApprover = getDefaultApprover(policy); + + // For policy using the optional or basic workflow, the manager is the policy default approver. + if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { + return defaultApprover; + } + + const employee = policy?.employeeList?.[ownerLogin ?? '']; + if (!employee && !defaultApprover) { + return ''; + } + + return employee?.submitsTo ?? defaultApprover ?? ''; +} + +function getManagerAccountID(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0) { + const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, reportTotal); + return managerEmail ? (getAccountIDsByLogins([managerEmail]).at(0) ?? -1) : -1; +} + +/** + * Returns the email the expense report should submit to per workspace approval config + * (approval rules, employee submitsTo, or default approver for basic/optional workflows). + */ +function getSubmitToEmail( + policy: OnyxEntry, + expenseReport: OnyxEntry, + ownerLogin: string | undefined, + rules: OnyxCollection, + shouldFallBackWhenManagerIsNotMember = false, +): string { + const approvalRules = policy?.rules?.approvalRules; + + if (!isSubmitAndClose(policy) && approvalRules?.length) { + const ruleApprover = getFirstRuleApprover(approvalRules, expenseReport, ownerLogin); + if (ruleApprover) { + return ruleApprover; + } + } + + const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal: expenseReport?.total ?? 0}, rules); + if (ruleMatch) { + return ruleMatch.forwardsTo ?? ''; + } + + const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, expenseReport?.total ?? 0); + + // Falls back to the default approver when the manager is unavailable. + if (shouldFallBackWhenManagerIsNotMember && managerEmail && !policy?.employeeList?.[managerEmail] && !getHRAdvancedModeFinalApprover(policy)) { + return getDefaultApprover(policy); + } + + return managerEmail; +} + +/** + * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. + */ +function getSubmitToAccountID( + policy: OnyxEntry, + expenseReport: OnyxEntry, + ownerLogin: string | undefined, + rules: OnyxCollection, + shouldFallBackWhenManagerIsNotMember = false, +): number { + const submitToEmail = getSubmitToEmail(policy, expenseReport, ownerLogin, rules, shouldFallBackWhenManagerIsNotMember); + return submitToEmail ? (getAccountIDsByLogins([submitToEmail]).at(0) ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; +} + +function getSubmitReportManagerAccountID( + policy: OnyxEntry, + expenseReport: OnyxEntry, + submitterLogin: string | undefined, + rules: OnyxCollection, + personalDetailsByLogins: PersonalDetailsByLogin, +): number | undefined { + const approvalRules = policy?.rules?.approvalRules; + const ruleApprover = !isSubmitAndClose(policy) && approvalRules?.length ? getFirstRuleApprover(approvalRules, expenseReport, submitterLogin) : ''; + const hasReliablePolicyRoute = + ([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy)) || + !!ruleApprover || + !!policy?.employeeList?.[submitterLogin ?? '']; + + if (!hasReliablePolicyRoute) { + return undefined; + } + + const submitToEmail = getSubmitToEmail(policy, expenseReport, submitterLogin, rules, true); + const submitToAccountID = submitToEmail ? personalDetailsByLogins[submitToEmail]?.accountID : undefined; + if (submitToAccountID === undefined || !isValidAccountRoute(submitToAccountID)) { + return undefined; + } + + return submitToAccountID; +} + +/** + * Returns the email of the account to forward the report to depending on the approver's approval limit. + * Used for advanced approval mode only. + * + * `submitterEmail` is needed to consult the workspace's approval workflow rules, which describe each hop in + * terms of who submitted the report as well as who is approving it. Without it only the legacy employeeList + * forwardsTo/overLimitForwardsTo is considered. + */ +function getForwardsToAccount(policy: OnyxEntry, employeeEmail: string, reportTotal: number, rules: OnyxCollection, submitterEmail?: string): string { + const ruleMatch = submitterEmail ? getForwardsToFromRules(policy, {submitterEmail, currentApproverEmail: employeeEmail, reportTotal}, rules) : undefined; + if (ruleMatch) { + return ruleMatch.forwardsTo ?? ''; + } + + if (!isControlOnAdvancedApprovalMode(policy)) { + return ''; + } + + const employee = policy?.employeeList?.[employeeEmail]; + if (!employee) { + return ''; + } + + const positiveReportTotal = Math.abs(reportTotal); + if (employee.approvalLimit && employee.overLimitForwardsTo && positiveReportTotal > employee.approvalLimit) { + return employee.overLimitForwardsTo; + } + return employee.forwardsTo ?? ''; +} + +function getWorkflowApprovalsUnavailable(policy: OnyxEntry) { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; +} + +export { + isSubmitterAndApprover, + getApprovalWorkflow, + getDefaultApprover, + hasCustomApprovalWorkflow, + getRuleApprovers, + evaluateApprovalWorkflowRule, + getForwardsToFromRules, + getManagerAccountEmail, + getManagerAccountID, + getSubmitToEmail, + getSubmitToAccountID, + getSubmitReportManagerAccountID, + getForwardsToAccount, + getWorkflowApprovalsUnavailable, +}; diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index 93817cd0593a..7242705ff5eb 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -2,32 +2,25 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleCon import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; import {getBankAccountFromID} from '@libs/actions/BankAccounts'; -import {getCategoryApproverRule} from '@libs/CategoryUtils'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import {getHRAdvancedModeFinalApprover} from '@libs/merge/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getIsOffline} from '@libs/NetworkState'; -import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; -import {isApprovalWorkflowRule, isRuleFilterComparison} from '@libs/RuleUtils'; -import {getAllSortedTransactions, getCategory, getTag} from '@libs/TransactionUtils'; import {generateAccountID} from '@libs/UserUtils'; -import {isPublicDomain, isValidAccountRoute} from '@libs/ValidationUtils'; +import {isPublicDomain} from '@libs/ValidationUtils'; import {getEffectiveWorkArrangement} from '@libs/WorkArrangementUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {PolicyType} from '@src/types/form/WorkspaceConfirmationForm'; -import type {Card, OnyxInputOrEntry, PersonalDetailsList, Policy, PolicyEmployeeList, Report, TravelSettings} from '@src/types/onyx'; -import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; -import type {ApprovalRule, CommuterExclusions} from '@src/types/onyx/Policy'; +import type {Card, OnyxInputOrEntry, PersonalDetailsList, Policy, PolicyEmployeeList, TravelSettings} from '@src/types/onyx'; +import type {CommuterExclusions} from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; -import type Rule from '@src/types/onyx/Rule'; -import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; import type {WorkspaceTravelSettings} from '@src/types/onyx/TravelSettings'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; @@ -35,10 +28,8 @@ import {Str} from 'expensify-common'; import {hasAccountingConnections} from './connections'; import {getPerDiemCustomUnit, isPerDiemEligiblePolicy} from './customUnits'; import {isTimeTrackingEnabled} from './features'; -import {canEditWorkspaceSettings, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyApprover, isPolicyOwner, isPolicyUser} from './permissions'; +import {canEditWorkspaceSettings, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyOwner, isPolicyUser} from './permissions'; import {isArchivedPolicy, isCollectPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; -import {getTagApproverRule} from './rules'; -import {isSubmitAndClose} from './workflows'; type MemberEmailsToAccountIDs = Record; @@ -49,24 +40,6 @@ type WorkspaceDetails = { name: string; }; -/** The report facts an approval workflow rule is matched against. */ -type ApprovalWorkflowContext = { - /** Email of the member who submitted the report. */ - submitterEmail: string; - - /** Email of the approver the report is currently with. Empty when the report has not been approved yet. */ - currentApproverEmail?: string; - - /** Total of the report, in the backend (cents) amount the rules' `amount` filters are written in. */ - reportTotal: number; -}; - -/** The outcome of matching a report against the policy's approval workflow rules. */ -type ApprovalWorkflowRuleMatch = { - /** Email the matched rule forwards the report to. Undefined when the rule finalizes the report instead. */ - forwardsTo?: string; -}; - /** * Returns true if the policy has no fieldList or its fieldList is empty. */ @@ -228,69 +201,6 @@ function isPolicyMemberWithoutPendingDelete(currentUserLogin: string | undefined return !!policyEmployee && policyEmployee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } -/** - * Resolve the email of the workspace's Authorized Payer (reimburser), matching the canonical resolution used across the app. - * Returns undefined when reimbursement is disabled or no payer can be resolved. - */ -function getReimburserEmail(policy: OnyxEntry): string | undefined { - if (!policy) { - return undefined; - } - - const reimbursementChoice = getReimbursementChoice(policy); - const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; - const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - - // Reimbursement is disabled for this workspace. - if (!isAutoReimbursement && !isManualReimbursement) { - return undefined; - } - - return policy.reimburser ?? policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined); -} - -/** - * Payer fields to merge into the successData of an ownership transfer. The backend keeps the former payer when the - * workspace has a bank account, so only reassign when there is none and the outgoing owner is the resolved payer. - */ -function getOwnerChangePayerSuccessData(policy: OnyxEntry, newOwnerLogin: string): NullishDeep { - if (!policy || policy.achAccount?.bankAccountID || getReimburserEmail(policy) !== policy.owner) { - return {}; - } - - return { - ...(policy.reimburser ? {reimburser: newOwnerLogin} : {}), - ...(policy.achAccount?.reimburser ? {achAccount: {reimburser: newOwnerLogin}} : {}), - }; -} - -function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { - if (!policy) { - return false; - } - - const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; - const reimbursementChoice = getReimbursementChoice(policy); - const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; - const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - - // Reimbursement is disabled for this workspace. - if (!isAutoReimbursement && !isManualReimbursement) { - return false; - } - - const reimburserEmail = getReimburserEmail(policy); - - // No designated reimburser means any workspace admin can pay. - if (!reimburserEmail) { - return isAdmin; - } - - const canPayOnPolicy = isAdmin || (!!currentUserLogin && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); - - return canPayOnPolicy && currentUserLogin === reimburserEmail; -} - /** Check if the passed employee holds an active Expensify Card on the policy, as reported by the backend in the policy's employeeList */ function hasActiveExpensifyCard(policy: OnyxEntry, employeeLogin: string) { const primaryLogin = policy?.primaryLoginsInvited?.[employeeLogin]; @@ -636,62 +546,6 @@ function tryNavigateToControlPolicyUpgrade(policy: OnyxEntry, upgradeFea return true; } -/** - * Resolves a workspace's reimbursement choice to one of the three values in `CONST.POLICY.REIMBURSEMENT_CHOICES`. - * Comparing the raw field instead makes a workspace reporting a deprecated value look like it has reimbursement - * disabled, which hides Pay on its approved reports. - */ -function getReimbursementChoice(policy: OnyxInputOrEntry): ValueOf | undefined { - switch (policy?.reimbursementChoice) { - case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO: - return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; - case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL: - return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - default: - return policy?.reimbursementChoice; - } -} - -function arePaymentsEnabled(policy: OnyxInputOrEntry): boolean { - return getReimbursementChoice(policy) !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; -} - -/** Whether the workspace has everything auto-pay approved reports needs: workflows, direct reimbursements, and a business bank account. */ -function isAutoPayApprovedReportsAvailable(policy: OnyxInputOrEntry): boolean { - return !!policy?.areWorkflowsEnabled && getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && !!policy?.achAccount?.bankAccountID; -} - -/** - * Returns true when the user is both a submitter and an approver, mirroring the Submit/Approve suggested-search eligibility in - * `getSuggestedSearchesVisibility` (SearchUIUtils): a submitter is a member of any group workspace, and an approver is a member of a - * group workspace with a non-optional approval flow whom `isPolicyApprover` recognizes (named approver or someone reports submit/forward to). - */ -function isSubmitterAndApprover(policies: OnyxCollection | null | undefined, currentUserEmail: string | undefined): boolean { - if (!policies || !currentUserEmail) { - return false; - } - let isSubmitter = false; - let isApprover = false; - for (const policy of Object.values(policies)) { - if (!policy) { - continue; - } - isSubmitter = isSubmitter || isGroupPolicy(policy); - if (!isApprover) { - const hasApprovalFlow = isGroupPolicy(policy) && !!policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; - isApprover = hasApprovalFlow && isPolicyApprover(policy, currentUserEmail); - } - if (isSubmitter && isApprover) { - return true; - } - } - return false; -} - -function isControlOnAdvancedApprovalMode(policy: OnyxInputOrEntry): boolean { - return policy?.type === CONST.POLICY.TYPE.CORPORATE && getApprovalWorkflow(policy) === CONST.POLICY.APPROVAL_MODE.ADVANCED; -} - /** Whether the user can add a category to the workspace straight from an expense's category picker */ function canCreateCategoryInSitu(policy: OnyxEntry, login?: string) { return canEditWorkspaceSettings(policy, login) && !hasAccountingConnections(policy) && !!policy?.areCategoriesEnabled; @@ -701,19 +555,6 @@ function goBackFromInvalidPolicy() { Navigation.goBack(ROUTES.WORKSPACES_LIST.route); } -function getApprovalWorkflow(policy: OnyxEntry): ValueOf { - if (policy?.type === CONST.POLICY.TYPE.PERSONAL) { - return CONST.POLICY.APPROVAL_MODE.OPTIONAL; - } - - return policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.ADVANCED; -} - -function getDefaultApprover(policy: OnyxEntry): string { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return policy?.approver || policy?.owner || ''; -} - /** * Whether an approver email points at someone who is no longer on the workspace. The owner always counts as a member, * and HR advanced (manager) mode is excluded, because its final approver doesn't have to be a workspace member. @@ -722,357 +563,6 @@ function isNonMemberApprover(policy: OnyxEntry, approverEmail: string | return !!approverEmail && !isPolicyMember(policy, approverEmail) && !getHRAdvancedModeFinalApprover(policy); } -/** - * Whether the policy has at least one custom approval workflow. A workflow is considered custom when either: - * - the default workflow was modified by changing its first approver or adding an "Approves to" user, or - * - a new workflow was created (a member submits to an approver other than the default approver). - */ -function hasCustomApprovalWorkflow(policy: OnyxEntry): boolean { - if (!policy) { - return false; - } - - const defaultApprover = getDefaultApprover(policy); - - // The default workflow's first approver was changed away from the workspace owner. - if (!!policy.approver && !!policy.owner && policy.approver !== policy.owner) { - return true; - } - - const employees = policy.employeeList ?? {}; - - // The default approver forwards approvals to someone, i.e. an "Approves to" user was added, - // either unconditionally (forwardsTo) or above an approval limit (overLimitForwardsTo). - const defaultApproverEmployee = employees[defaultApprover]; - if (!!defaultApprover && (!!defaultApproverEmployee?.forwardsTo || !!defaultApproverEmployee?.overLimitForwardsTo)) { - return true; - } - - // A new workflow exists when a member submits to an approver other than the default approver. - return Object.values(employees).some( - (employee) => employee?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!employee?.submitsTo && employee.submitsTo !== defaultApprover, - ); -} - -function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { - const categoryApprovers: string[] = []; - const tagApprovers: string[] = []; - const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); - - // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. - // Category approvers are prioritized, then tag approvers. - for (let i = 0; i < allReportTransactions.length; i++) { - const transaction = allReportTransactions.at(i); - const tag = getTag(transaction); - const category = getCategory(transaction); - const categoryApprover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; - const tagApprover = getTagApproverRule(policy, tag)?.approver; - if (categoryApprover) { - categoryApprovers.push(categoryApprover); - } - - if (tagApprover) { - tagApprovers.push(tagApprover); - } - } - - // Return the unique approvers list - return [...new Set([...categoryApprovers, ...tagApprovers])]; -} - -function getFirstRuleApprover(approvalRules: ApprovalRule[], expenseReport: OnyxEntry, ownerLogin: string | undefined) { - // Pre-build a lookup map of { category: { value → approver }, tag: { value → approver } } - // from the policy's approval rules so that each transaction's category/tag can be resolved in O(1). - const rulesMap: Record<'category' | 'tag', Record> = { - category: {}, - tag: {}, - }; - - for (let i = 0; i < approvalRules.length; i++) { - const rule = approvalRules.at(i); - if (!rule) { - continue; - } - for (let j = 0; j < rule.applyWhen.length; j++) { - const applyWhen = rule.applyWhen.at(j); - if (applyWhen?.condition !== CONST.POLICY.RULE_CONDITIONS.MATCHES) { - continue; - } - if (applyWhen.field === CONST.POLICY.FIELDS.CATEGORY || applyWhen.field === CONST.POLICY.FIELDS.TAG) { - rulesMap[applyWhen.field][applyWhen.value] = rule.approver; - } - } - } - - if (isEmptyObject(rulesMap.category) && isEmptyObject(rulesMap.tag)) { - return ''; - } - - const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); - - if (!allReportTransactions.length) { - return ''; - } - - let firstCategoryApprover = ''; - let firstTagApprover = ''; - - for (let i = 0; i < allReportTransactions.length; i++) { - const transaction = allReportTransactions.at(i); - const category = getCategory(transaction); - const categoryApprover = rulesMap.category[category]; - - // Category approvers take strict priority over tag approvers. - // Break immediately on the first match so we don't keep scanning transactions unnecessarily. - if (categoryApprover && categoryApprover !== ownerLogin) { - firstCategoryApprover = categoryApprover; - break; - } - - // Only look for a tag approver if we haven't found one yet — no need to re-check on subsequent transactions. - if (!firstTagApprover) { - const tag = getTag(transaction); - const tagApprover = rulesMap.tag[tag]; - - if (tagApprover && tagApprover !== ownerLogin) { - firstTagApprover = tagApprover; - } - } - } - - return firstCategoryApprover || firstTagApprover; -} - -function matchesApprovalWorkflowEmailComparison(node: RuleFilterComparison, email: string | undefined): boolean { - const expectedEmails = (Array.isArray(node.right) ? node.right : [node.right]).map((value) => String(value).toLowerCase()); - const isMatch = !!email && expectedEmails.includes(email.toLowerCase()); - return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO ? !isMatch : isMatch; -} - -function matchesApprovalWorkflowAmountComparison(node: RuleFilterComparison, amount: number): boolean { - const expectedAmount = typeof node.right === 'number' ? node.right : Number(node.right); - if (Number.isNaN(expectedAmount)) { - return false; - } - - switch (node.operator) { - case CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO: - return amount === expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO: - return amount !== expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN: - return amount > expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO: - return amount >= expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN: - return amount < expectedAmount; - case CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO: - return amount <= expectedAmount; - default: - return false; - } -} - -function evaluateApprovalWorkflowFilter(node: RuleFilterNode, context: ApprovalWorkflowContext): boolean { - if (!isRuleFilterComparison(node)) { - const left = evaluateApprovalWorkflowFilter(node.left, context); - const right = evaluateApprovalWorkflowFilter(node.right, context); - return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR ? left || right : left && right; - } - - switch (node.left) { - case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: - return matchesApprovalWorkflowEmailComparison(node, context.submitterEmail); - - // A report with no current approver has not been approved by anybody yet, so a rule based on who - // approved it last cannot apply. - case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: - return !!context.currentApproverEmail && matchesApprovalWorkflowEmailComparison(node, context.currentApproverEmail); - case CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT: - return matchesApprovalWorkflowAmountComparison(node, Math.abs(context.reportTotal)); - - default: - return false; - } -} - -/** - * True when every condition in the rule's filter tree is true for `context`. - */ -function evaluateApprovalWorkflowRule(rule: ApprovalWorkflowRule, context: ApprovalWorkflowContext): boolean { - return evaluateApprovalWorkflowFilter(rule.filters, context); -} - -/** - * Check the policy's approval workflow rules to determine where the report goes next. - */ -function getForwardsToFromRules(policy: OnyxEntry, context: ApprovalWorkflowContext, rules: OnyxCollection): ApprovalWorkflowRuleMatch | undefined { - if (!policy?.id || !context.submitterEmail) { - return undefined; - } - - const trigger = context.currentApproverEmail ? CONST.RULES.TRIGGERS.REPORT_APPROVE : CONST.RULES.TRIGGERS.REPORT_SUBMIT; - - // Sort by Onyx key so the rule picked stays the same across evaluations when more than one matches (which should not happen). - const ruleKeys = Object.keys(rules ?? {}).sort(); - for (const ruleKey of ruleKeys) { - const rule = rules?.[ruleKey]; - if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policy.id || rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - continue; - } - if (!isApprovalWorkflowRule(rule) || !Object.values(rule.triggers ?? {}).includes(trigger)) { - continue; - } - if (!evaluateApprovalWorkflowRule(rule, context)) { - continue; - } - - return {forwardsTo: Object.values(rule.actions ?? {}).find((action) => action.name === CONST.RULES.ACTIONS.FORWARD_TO)?.approver}; - } - - return undefined; -} - -function getManagerAccountEmail(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0): string { - const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal}, rules); - if (ruleMatch) { - return ruleMatch.forwardsTo ?? ''; - } - - const defaultApprover = getDefaultApprover(policy); - - // For policy using the optional or basic workflow, the manager is the policy default approver. - if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { - return defaultApprover; - } - - const employee = policy?.employeeList?.[ownerLogin ?? '']; - if (!employee && !defaultApprover) { - return ''; - } - - return employee?.submitsTo ?? defaultApprover ?? ''; -} - -function getManagerAccountID(policy: OnyxEntry, ownerLogin: string | undefined, rules: OnyxCollection, reportTotal = 0) { - const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, reportTotal); - return managerEmail ? (getAccountIDsByLogins([managerEmail]).at(0) ?? -1) : -1; -} - -/** - * Returns the email the expense report should submit to per workspace approval config - * (approval rules, employee submitsTo, or default approver for basic/optional workflows). - */ -function getSubmitToEmail( - policy: OnyxEntry, - expenseReport: OnyxEntry, - ownerLogin: string | undefined, - rules: OnyxCollection, - shouldFallBackWhenManagerIsNotMember = false, -): string { - const approvalRules = policy?.rules?.approvalRules; - - if (!isSubmitAndClose(policy) && approvalRules?.length) { - const ruleApprover = getFirstRuleApprover(approvalRules, expenseReport, ownerLogin); - if (ruleApprover) { - return ruleApprover; - } - } - - const ruleMatch = getForwardsToFromRules(policy, {submitterEmail: ownerLogin ?? '', reportTotal: expenseReport?.total ?? 0}, rules); - if (ruleMatch) { - return ruleMatch.forwardsTo ?? ''; - } - - const managerEmail = getManagerAccountEmail(policy, ownerLogin, rules, expenseReport?.total ?? 0); - - // Falls back to the default approver when the manager is unavailable. - if (shouldFallBackWhenManagerIsNotMember && managerEmail && !policy?.employeeList?.[managerEmail] && !getHRAdvancedModeFinalApprover(policy)) { - return getDefaultApprover(policy); - } - - return managerEmail; -} - -/** - * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. - */ -function getSubmitToAccountID( - policy: OnyxEntry, - expenseReport: OnyxEntry, - ownerLogin: string | undefined, - rules: OnyxCollection, - shouldFallBackWhenManagerIsNotMember = false, -): number { - const submitToEmail = getSubmitToEmail(policy, expenseReport, ownerLogin, rules, shouldFallBackWhenManagerIsNotMember); - return submitToEmail ? (getAccountIDsByLogins([submitToEmail]).at(0) ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; -} - -function getSubmitReportManagerAccountID( - policy: OnyxEntry, - expenseReport: OnyxEntry, - submitterLogin: string | undefined, - rules: OnyxCollection, - personalDetailsByLogins: PersonalDetailsByLogin, -): number | undefined { - const approvalRules = policy?.rules?.approvalRules; - const ruleApprover = !isSubmitAndClose(policy) && approvalRules?.length ? getFirstRuleApprover(approvalRules, expenseReport, submitterLogin) : ''; - const hasReliablePolicyRoute = - ([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy)) || - !!ruleApprover || - !!policy?.employeeList?.[submitterLogin ?? '']; - - if (!hasReliablePolicyRoute) { - return undefined; - } - - const submitToEmail = getSubmitToEmail(policy, expenseReport, submitterLogin, rules, true); - const submitToAccountID = submitToEmail ? personalDetailsByLogins[submitToEmail]?.accountID : undefined; - if (submitToAccountID === undefined || !isValidAccountRoute(submitToAccountID)) { - return undefined; - } - - return submitToAccountID; -} - -/** - * Returns the email of the account to forward the report to depending on the approver's approval limit. - * Used for advanced approval mode only. - * - * `submitterEmail` is needed to consult the workspace's approval workflow rules, which describe each hop in - * terms of who submitted the report as well as who is approving it. Without it only the legacy employeeList - * forwardsTo/overLimitForwardsTo is considered. - */ -function getForwardsToAccount(policy: OnyxEntry, employeeEmail: string, reportTotal: number, rules: OnyxCollection, submitterEmail?: string): string { - const ruleMatch = submitterEmail ? getForwardsToFromRules(policy, {submitterEmail, currentApproverEmail: employeeEmail, reportTotal}, rules) : undefined; - if (ruleMatch) { - return ruleMatch.forwardsTo ?? ''; - } - - if (!isControlOnAdvancedApprovalMode(policy)) { - return ''; - } - - const employee = policy?.employeeList?.[employeeEmail]; - if (!employee) { - return ''; - } - - const positiveReportTotal = Math.abs(reportTotal); - if (employee.approvalLimit && employee.overLimitForwardsTo && positiveReportTotal > employee.approvalLimit) { - return employee.overLimitForwardsTo; - } - return employee.forwardsTo ?? ''; -} - -/** - * Returns the accountID of the policy reimburser, if not available returns -1. - */ -function getReimburserAccountID(policy: OnyxEntry): number { - const reimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? ''; - return reimburserEmail ? (getAccountIDsByLogins([reimburserEmail]).at(0) ?? -1) : -1; -} - function getAdminEmployees(policy: OnyxEntry): PolicyEmployee[] { if (!policy?.employeeList) { return []; @@ -1172,10 +662,6 @@ function getDomainNameForPolicy(policyID?: string): string { return `${CONST.EXPENSIFY_POLICY_DOMAIN}${policyID.toLowerCase()}${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`; } -function getWorkflowApprovalsUnavailable(policy: OnyxEntry) { - return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL || !!policy?.errorFields?.approvalMode; -} - function getUserFriendlyWorkspaceType(workspaceType: ValueOf, translate: LocalizedTranslate) { switch (workspaceType) { case CONST.POLICY.TYPE.CORPORATE: @@ -1494,13 +980,6 @@ export { canUnarchivePolicy, isPolicyMember, isMemberInHomeAndOfficeWorkspace, - isPolicyPayer, - getReimburserEmail, - getOwnerChangePayerSuccessData, - arePaymentsEnabled, - isAutoPayApprovedReportsAvailable, - getReimbursementChoice, - isSubmitterAndApprover, shouldShowPolicy, getActiveAdminWorkspaces, hasActiveAdminWorkspaces, @@ -1515,38 +994,24 @@ export { sortWorkspacesBySelected, goBackWhenEnableFeature, navigateToExpensifyCardPage, - getDefaultApprover, isNonMemberApprover, - hasCustomApprovalWorkflow, - getApprovalWorkflow, - getReimburserAccountID, hasOnlyPersonalPolicies, getGroupPaidPolicies, getGroupPoliciesWhereReportCanBeCreated, getDefaultChatEnabledPolicy, getDefaultChatEnabledPolicySelection, - getForwardsToAccount, - getForwardsToFromRules, - evaluateApprovalWorkflowRule, - getSubmitToAccountID, - getSubmitReportManagerAccountID, getDomainNameForPolicy, - getWorkflowApprovalsUnavailable, getUserFriendlyWorkspaceType, getDefaultWorkspacePlanType, isPolicyAccessible, hasOtherControlWorkspaces, shouldBlockWorkspaceDeletionForInvoicifyUser, - getManagerAccountEmail, - getSubmitToEmail, - getRuleApprovers, canModifyPlan, getAdminsPrivateEmailDomains, getMostFrequentEmailDomain, getPolicyIDFromDomainName, getPolicyForAssignedCard, getDescriptionForPolicyDomainCard, - getManagerAccountID, getActiveEmployeeWorkspaces, isPolicyMemberWithoutPendingDelete, getActivePoliciesWithExpenseChatAndPerDiemEnabled, @@ -1579,5 +1044,7 @@ export * from './rules'; export * from './features'; export * from './workflows'; export * from './errors'; +export * from './approvals'; +export * from './payments'; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/PolicyUtils/payments.ts b/src/libs/PolicyUtils/payments.ts new file mode 100644 index 000000000000..866a3a4098fb --- /dev/null +++ b/src/libs/PolicyUtils/payments.ts @@ -0,0 +1,111 @@ +/** + * The workspace reimburser and payer, the reimbursement choice, and auto-pay availability. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; + +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; + +import type {NullishDeep, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {canMemberWrite} from './permissions'; + +/** + * Resolve the email of the workspace's Authorized Payer (reimburser), matching the canonical resolution used across the app. + * Returns undefined when reimbursement is disabled or no payer can be resolved. + */ +function getReimburserEmail(policy: OnyxEntry): string | undefined { + if (!policy) { + return undefined; + } + + const reimbursementChoice = getReimbursementChoice(policy); + const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + + // Reimbursement is disabled for this workspace. + if (!isAutoReimbursement && !isManualReimbursement) { + return undefined; + } + + return policy.reimburser ?? policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined); +} + +/** + * Payer fields to merge into the successData of an ownership transfer. The backend keeps the former payer when the + * workspace has a bank account, so only reassign when there is none and the outgoing owner is the resolved payer. + */ +function getOwnerChangePayerSuccessData(policy: OnyxEntry, newOwnerLogin: string): NullishDeep { + if (!policy || policy.achAccount?.bankAccountID || getReimburserEmail(policy) !== policy.owner) { + return {}; + } + + return { + ...(policy.reimburser ? {reimburser: newOwnerLogin} : {}), + ...(policy.achAccount?.reimburser ? {achAccount: {reimburser: newOwnerLogin}} : {}), + }; +} + +function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { + if (!policy) { + return false; + } + + const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; + const reimbursementChoice = getReimbursementChoice(policy); + const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + + // Reimbursement is disabled for this workspace. + if (!isAutoReimbursement && !isManualReimbursement) { + return false; + } + + const reimburserEmail = getReimburserEmail(policy); + + // No designated reimburser means any workspace admin can pay. + if (!reimburserEmail) { + return isAdmin; + } + + const canPayOnPolicy = isAdmin || (!!currentUserLogin && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); + + return canPayOnPolicy && currentUserLogin === reimburserEmail; +} + +/** + * Resolves a workspace's reimbursement choice to one of the three values in `CONST.POLICY.REIMBURSEMENT_CHOICES`. + * Comparing the raw field instead makes a workspace reporting a deprecated value look like it has reimbursement + * disabled, which hides Pay on its approved reports. + */ +function getReimbursementChoice(policy: OnyxInputOrEntry): ValueOf | undefined { + switch (policy?.reimbursementChoice) { + case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO: + return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + case CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL: + return CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + default: + return policy?.reimbursementChoice; + } +} + +function arePaymentsEnabled(policy: OnyxInputOrEntry): boolean { + return getReimbursementChoice(policy) !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; +} + +/** Whether the workspace has everything auto-pay approved reports needs: workflows, direct reimbursements, and a business bank account. */ +function isAutoPayApprovedReportsAvailable(policy: OnyxInputOrEntry): boolean { + return !!policy?.areWorkflowsEnabled && getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && !!policy?.achAccount?.bankAccountID; +} + +/** + * Returns the accountID of the policy reimburser, if not available returns -1. + */ +function getReimburserAccountID(policy: OnyxEntry): number { + const reimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? ''; + return reimburserEmail ? (getAccountIDsByLogins([reimburserEmail]).at(0) ?? -1) : -1; +} + +export {getReimburserEmail, getOwnerChangePayerSuccessData, isPolicyPayer, getReimbursementChoice, arePaymentsEnabled, isAutoPayApprovedReportsAvailable, getReimburserAccountID}; diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index 765dd9eb4c60..81f26b8ef4f9 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -96,6 +96,8 @@ jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual>('@libs/PolicyUtils/policyType'), ...jest.requireActual>('@libs/PolicyUtils/permissions'), ...jest.requireActual>('@libs/PolicyUtils/workflows'), + ...jest.requireActual>('@libs/PolicyUtils/approvals'), + ...jest.requireActual>('@libs/PolicyUtils/payments'), isPaidGroupPolicy: jest.fn().mockReturnValue(true), isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), })); diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 0e214ffb5b74..8204022dee4f 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -115,6 +115,8 @@ jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual>('@libs/PolicyUtils/policyType'), ...jest.requireActual>('@libs/PolicyUtils/permissions'), ...jest.requireActual>('@libs/PolicyUtils/workflows'), + ...jest.requireActual>('@libs/PolicyUtils/approvals'), + ...jest.requireActual>('@libs/PolicyUtils/payments'), isPreferredExporter: jest.fn().mockReturnValue(true), hasAccountingConnections: jest.fn().mockReturnValue(true), isPolicyAdmin: jest.fn().mockReturnValue(true), From f95a96278dc3fad074e741b8b049cdc4a8cea8d4 Mon Sep 17 00:00:00 2001 From: Linh Date: Thu, 15 Oct 2026 11:59:51 +0700 Subject: [PATCH 730/781] Rename allTransactions to moneyReportTransactions --- .../MoneyReportHeaderSecondaryActions.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index feaae4fe94c3..ea2554c1086e 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -156,7 +156,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); // While offline, keep pending-delete transactions so a queued-for-delete expense still counts as a real transaction until the delete syncs. const nonPendingDeleteTransactions = Object.values(reportTransactions).filter((t) => isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const allTransactions = Object.values(reportTransactions); + const moneyReportTransactions = Object.values(reportTransactions); const singleTransaction = nonPendingDeleteTransactions.length === 1 ? nonPendingDeleteTransactions.at(0) : undefined; const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(singleTransaction?.comment?.originalTransactionID)}`); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); @@ -173,7 +173,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - const isAnyTransactionOnHold = hasHeldExpensesReportUtils(allTransactions); + const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyReportTransactions); const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); const getChatReportActions = usePayChatReportActions(chatReport, existingB2BInvoiceReport); @@ -209,7 +209,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo paymentMethodType: type, chatReport, invoiceReport: moneyRequestReport, - invoiceReportTransactions: allTransactions, + invoiceReportTransactions: moneyReportTransactions, introSelected, currentUserAccountIDParam: accountID, currentUserEmailParam: email ?? '', @@ -235,7 +235,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo paymentType: type, chatReport, iouReport: moneyRequestReport, - iouReportTransactions: allTransactions, + iouReportTransactions: moneyReportTransactions, introSelected, currentUserAccountID: accountID, currentUserLogin: currentUserLogin ?? '', @@ -285,9 +285,10 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, currentUserLogin ?? '', accountID, undefined, true, isChatReportArchived, invoiceReceiverPolicy); const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; - const hasOnlyPendingTransactions = allTransactions.length > 0 && allTransactions.every((t) => isPending(t)); + const hasOnlyPendingTransactions = moneyReportTransactions.length > 0 && moneyReportTransactions.every((t) => isPending(t)); const shouldShowApproveButton = - (canApproveIOU(moneyRequestReport, policy, reportMetadata, currentUserPersonalDetails.accountID, allTransactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning; + (canApproveIOU(moneyRequestReport, policy, reportMetadata, currentUserPersonalDetails.accountID, moneyReportTransactions) && !hasOnlyPendingTransactions) || + isApprovedAnimationRunning; const isApproveDisabled = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions, convertToDisplayString); From 2b50def9a8f7a5d841a97fc0fe4d0a39f15cc7f7 Mon Sep 17 00:00:00 2001 From: "Maruf Sharifi (via MelvinBot)" Date: Thu, 15 Oct 2026 06:35:27 +0000 Subject: [PATCH 731/781] Revert merge conflict resolution that dropped this PR's changes Merge commit 423c9549d7 resolved the conflicts in favor of main and removed the PR's changes. This reapplies 93a3994982 on top of the merge so main's changes are kept. Co-authored-by: Maruf Sharifi --- src/hooks/useSidebarOrderedReports.tsx | 22 +++++ tests/unit/useSidebarOrderedReportsTest.tsx | 89 +++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 80dd9483a600..a2f168aba8a3 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -8,6 +8,7 @@ import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; @@ -231,6 +232,26 @@ function SidebarOrderedReportsContextProvider({ // Rather than falling through to a full scan of all reports, recheck only the already-displayed reports. let effectiveUpdatedReports = updatedReports.length === 0 && hasCachedReports ? Object.keys(currentReportsToDisplay) : updatedReports; + // A report can be filtered out before its derived attributes are ready (e.g. a new DM that arrives before its + // expense preview). It is then not displayed, so the fallback above never rechecks it. Also recheck reports whose + // visibility-related attributes changed. Compare fields, not references: derived writes recreate every entry, + // so a reference diff would bring back a full scan. Skip the first hydration for the same reason. + if (hasCachedReports && reportAttributes !== prevReportAttributes && !isEmptyObject(prevReportAttributes)) { + const reportsToUpdate = new Set(effectiveUpdatedReports); + for (const [reportID, attributes] of Object.entries(reportAttributes ?? {})) { + const prevAttributes = prevReportAttributes[reportID]; + if ( + !prevAttributes || + attributes.requiresAttention !== prevAttributes.requiresAttention || + attributes.isEmpty !== prevAttributes.isEmpty || + attributes.brickRoadStatus !== prevAttributes.brickRoadStatus + ) { + reportsToUpdate.add(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + } + } + effectiveUpdatedReports = Array.from(reportsToUpdate); + } + // When guide personal details hydrate after the reports collection, guideAccountIDs changes but // getUpdatedReports() returns no report keys. Re-evaluate all reports so domain rooms previously // filtered out can appear in the LHN. @@ -290,6 +311,7 @@ function SidebarOrderedReportsContextProvider({ transactionViolations, reportNameValuePairs, reportAttributes, + prevReportAttributes, reportsDrafts, isOffline, clearCacheDummyCounter, diff --git a/tests/unit/useSidebarOrderedReportsTest.tsx b/tests/unit/useSidebarOrderedReportsTest.tsx index 25a233e49436..6818a6d1a926 100644 --- a/tests/unit/useSidebarOrderedReportsTest.tsx +++ b/tests/unit/useSidebarOrderedReportsTest.tsx @@ -10,6 +10,7 @@ import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAttributesDerivedValue} from '@src/types/onyx'; +import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import type {OnyxMultiSetInput} from 'react-native-onyx'; @@ -542,4 +543,92 @@ describe('useSidebarOrderedReports', () => { const callForReport2 = mockSidebarUtils.updateReportsToDisplayInLHN.mock.calls.find((call) => call[0]?.updatedReportsKeys.includes(`${ONYXKEYS.COLLECTION.REPORT}2`)); expect(callForReport2?.[0]?.updatedReportsKeys).toEqual([`${ONYXKEYS.COLLECTION.REPORT}2`]); }); + + describe('reportAttributes changes', () => { + const createReportAttributes = (attributes: Partial = {}): ReportAttributes => ({ + reportName: 'Chat', + isEmpty: false, + brickRoadStatus: undefined, + requiresAttention: false, + reportErrors: {}, + ...attributes, + }); + + const displayedReportID = '1'; + const newDMReportID = '2'; + + // Renders the LHN with one displayed report and a new DM that is filtered out because its attributes are not ready yet. + const renderWithExcludedReport = async () => { + const displayedReports = createMockReports({ + report1: {reportName: 'Chat A'}, + }); + // The LHN cache is keyed by the report's Onyx key + mockSidebarUtils.getReportsToDisplayInLHN.mockReturnValue({[`${ONYXKEYS.COLLECTION.REPORT}${displayedReportID}`]: displayedReports[displayedReportID]}); + mockSidebarUtils.updateReportsToDisplayInLHN.mockImplementation(({displayedReports: reports}) => reports); + + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${displayedReportID}`, displayedReports[displayedReportID]); + await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {reports: {[displayedReportID]: createReportAttributes()}, locale: 'en'}); + }); + + renderHook(() => useSidebarOrderedReports(), { + wrapper: TestWrapper, + }); + await waitForBatchedUpdatesWithAct(); + + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${newDMReportID}`, {reportID: newDMReportID, type: CONST.REPORT.TYPE.CHAT} as Report); + }); + await waitForBatchedUpdatesWithAct(); + }; + + const getUpdatedReportsKeys = () => mockSidebarUtils.updateReportsToDisplayInLHN.mock.calls.flatMap((call) => call[0]?.updatedReportsKeys ?? []); + + it('should recheck a report that is not displayed when its attributes make it require attention', async () => { + // Given the LHN cache is built and a new DM was filtered out before its attributes were calculated + await renderWithExcludedReport(); + mockSidebarUtils.updateReportsToDisplayInLHN.mockClear(); + + // When the derived attributes later mark the DM as requiring attention, with no change to the report itself + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + reports: {[displayedReportID]: createReportAttributes(), [newDMReportID]: createReportAttributes({requiresAttention: true})}, + locale: 'en', + }); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the DM is rechecked, so it can be added to the LHN without a full rebuild, and the displayed report is still + // rechecked with the new attributes by the existing fallback + expect(getUpdatedReportsKeys()).toEqual(expect.arrayContaining([`${ONYXKEYS.COLLECTION.REPORT}${newDMReportID}`, `${ONYXKEYS.COLLECTION.REPORT}${displayedReportID}`])); + }); + + it('should not recheck a report when its attributes are recreated with the same visibility fields', async () => { + // Given the DM already has attributes that keep it out of the LHN + await renderWithExcludedReport(); + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + reports: {[displayedReportID]: createReportAttributes(), [newDMReportID]: createReportAttributes({isEmpty: true})}, + locale: 'en', + }); + }); + await waitForBatchedUpdatesWithAct(); + mockSidebarUtils.updateReportsToDisplayInLHN.mockClear(); + + // When the derived value is written again with new objects but the same visibility fields + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + reports: { + [displayedReportID]: createReportAttributes({reportName: 'Renamed'}), + [newDMReportID]: createReportAttributes({isEmpty: true, reportName: 'Renamed'}), + }, + locale: 'en', + }); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the hidden DM is not rechecked, which keeps reportAttributes writes from triggering a full scan + expect(getUpdatedReportsKeys()).not.toContain(`${ONYXKEYS.COLLECTION.REPORT}${newDMReportID}`); + }); + }); }); From cf4b40bb1fffec8f1a1f8befbafcb6cfc67ad81e Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 15 Oct 2026 08:42:48 +0200 Subject: [PATCH 732/781] fix: name the dynamic form list entry an error belongs to --- .../DynamicForm/utils/getDynamicFieldErrors.ts | 15 +++++++++------ src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + .../DynamicForm/getDynamicFieldErrorsTest.ts | 10 ++++++---- 13 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/DynamicForm/utils/getDynamicFieldErrors.ts b/src/components/DynamicForm/utils/getDynamicFieldErrors.ts index 83de65e1e972..de0bfcb55b61 100644 --- a/src/components/DynamicForm/utils/getDynamicFieldErrors.ts +++ b/src/components/DynamicForm/utils/getDynamicFieldErrors.ts @@ -31,6 +31,7 @@ import getVisibleFields from './getVisibleFields'; import isSupportedField from './isSupportedField'; import {getListItems, getListItemSensitiveAnswers} from './listItems'; import logSchemaProblem from './logSchemaProblem'; +import summarizeListItem, {getListItemFallbackTitle} from './summarizeListItem'; /** Checks a field the user has answered. Unanswered fields only get the required check. */ type FieldValidator = (field: DynamicFormFieldOfType, values: DynamicFormValues, translate: LocalizedTranslate) => string[]; @@ -102,7 +103,7 @@ function getMultiChoiceErrors(field: DynamicFormFieldOfType<'multiselect' | 'cou return !hasNonStringEntry && keys.every((key) => offeredKeys.has(key)) ? [] : [translate('dynamicForm.error.invalidOption')]; } -/** The list shows one error, so the first entry with a problem names it. The flow keeps sensitive answers in memory apart from their entry, so one lost with the memory is asked for again. */ +/** The list shows one error, so the first entry with a problem gives it, prefixed by the entry title so the user knows which one to open. The flow keeps sensitive answers in memory apart from their entry, so one lost with the memory is asked for again. */ function getListErrors(field: DynamicFormListField, values: DynamicFormValues, translate: LocalizedTranslate): string[] { const items = getListItems(values[field.key]); const messages: string[] = []; @@ -112,11 +113,13 @@ function getListErrors(field: DynamicFormListField, values: DynamicFormValues, t if (field.maxItems !== undefined && items.length > field.maxItems) { messages.push(translate('dynamicForm.error.tooManyItems', {max: field.maxItems})); } - const firstItemError = items - .map((item) => Object.values(getDynamicFieldErrors(field.itemFields, {...item, ...getListItemSensitiveAnswers(field, item.id, values)}, translate)).at(0)) - .find(Boolean); - if (firstItemError) { - messages.push(firstItemError); + const fallbackTitle = getListItemFallbackTitle(field, translate); + for (const item of items) { + const error = Object.values(getDynamicFieldErrors(field.itemFields, {...item, ...getListItemSensitiveAnswers(field, item.id, values)}, translate)).at(0); + if (error) { + messages.push(translate('dynamicForm.error.itemError', {item: summarizeListItem(item, field.itemFields, translate, fallbackTitle).title, error})); + break; + } } return messages; } diff --git a/src/languages/de.ts b/src/languages/de.ts index f92faf6b461f..f3849a05e0dc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4068,6 +4068,7 @@ ${amount} für ${merchant} – ${date}`, outOfRange: ({min, max}: {min: number; max: number}) => `Gib einen Wert zwischen ${min} und ${max} ein`, tooFewItems: ({min}: {min: number}) => `Füge mindestens ${min} hinzu`, tooManyItems: ({max}: {max: number}) => `Füge höchstens ${max} hinzu`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, addItem: ({item}: {item: string}) => `${item} hinzufügen`, removeItemTitle: ({item}: {item: string}) => `${item} entfernen?`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 8e5cf83235cb..bea141c8f367 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4116,6 +4116,7 @@ ${amount} για ${merchant} - ${date}`, outOfRange: ({min, max}: {min: number; max: number}) => `Εισαγάγετε μια τιμή μεταξύ ${min} και ${max}`, tooFewItems: ({min}: {min: number}) => `Προσθέστε τουλάχιστον ${min}`, tooManyItems: ({max}: {max: number}) => `Προσθέστε το πολύ ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, addItem: ({item}: {item: string}) => `Προσθήκη ${item}`, removeItemTitle: ({item}: {item: string}) => `Αφαίρεση ${item};`, diff --git a/src/languages/en.ts b/src/languages/en.ts index c2e20ec9d0c8..d0260e386b12 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4232,6 +4232,7 @@ const translations = { outOfRange: ({min, max}: {min: number; max: number}) => `Enter a value between ${min} and ${max}`, tooFewItems: ({min}: {min: number}) => `Add at least ${min}`, tooManyItems: ({max}: {max: number}) => `Add at most ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, addItem: ({item}: {item: string}) => `Add ${item}`, removeItemTitle: ({item}: {item: string}) => `Remove ${item}?`, diff --git a/src/languages/es.ts b/src/languages/es.ts index b5a38cc81e66..85ca137dd420 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3979,6 +3979,7 @@ ${amount} para ${merchant} - ${date}`, outOfRange: ({min, max}: {min: number; max: number}) => `Introduce un valor entre ${min} y ${max}`, tooFewItems: ({min}: {min: number}) => `Añade al menos ${min}`, tooManyItems: ({max}: {max: number}) => `Añade como máximo ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, addItem: ({item}: {item: string}) => `Añadir ${item}`, removeItemTitle: ({item}: {item: string}) => `¿Eliminar ${item}?`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 72ffa40c1a21..d996645a6123 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4075,6 +4075,7 @@ ${amount} pour ${merchant} - ${date}`, outOfRange: ({min, max}: {min: number; max: number}) => `Saisissez une valeur entre ${min} et ${max}`, tooFewItems: ({min}: {min: number}) => `Ajoutez au moins ${min}`, tooManyItems: ({max}: {max: number}) => `Ajoutez au plus ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item} : ${error}`, }, addItem: ({item}: {item: string}) => `Ajouter ${item}`, removeItemTitle: ({item}: {item: string}) => `Supprimer ${item} ?`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 488db402f139..1bc7feabfa3d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4046,6 +4046,7 @@ ${amount} per ${merchant} - ${date}`, outOfRange: ({min, max}: {min: number; max: number}) => `Inserisci un valore tra ${min} e ${max}`, tooFewItems: ({min}: {min: number}) => `Aggiungi almeno ${min}`, tooManyItems: ({max}: {max: number}) => `Aggiungi al massimo ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, addItem: ({item}: {item: string}) => `Aggiungi ${item}`, removeItemTitle: ({item}: {item: string}) => `Rimuovere ${item}?`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 129bd3995252..7d4b01691044 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4014,6 +4014,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' outOfRange: ({min, max}: {min: number; max: number}) => `${min}から${max}の間の値を入力してください`, tooFewItems: ({min}: {min: number}) => `${min}件以上追加してください`, tooManyItems: ({max}: {max: number}) => `${max}件以内で追加してください`, + itemError: ({item, error}: {item: string; error: string}) => `${item}:${error}`, }, addItem: ({item}: {item: string}) => `${item}を追加`, removeItemTitle: ({item}: {item: string}) => `${item}を削除しますか?`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c24e0b76d7be..7281e46a0d95 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4048,6 +4048,7 @@ ${amount} voor ${merchant} - ${date}`, outOfRange: ({min, max}: {min: number; max: number}) => `Voer een waarde in tussen ${min} en ${max}`, tooFewItems: ({min}: {min: number}) => `Voeg er minimaal ${min} toe`, tooManyItems: ({max}: {max: number}) => `Voeg er maximaal ${max} toe`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, addItem: ({item}: {item: string}) => `${item} toevoegen`, removeItemTitle: ({item}: {item: string}) => `${item} verwijderen?`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 2c1c1b9f124f..6c0f2af82994 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4080,6 +4080,7 @@ ${amount} dla ${merchant} - ${date}`, outOfRange: ({min, max}: {min: number; max: number}) => `Wprowadź wartość od ${min} do ${max}`, tooFewItems: ({min}: {min: number}) => `Dodaj co najmniej ${min}`, tooManyItems: ({max}: {max: number}) => `Dodaj maksymalnie ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, addItem: ({item}: {item: string}) => `Dodaj ${item}`, removeItemTitle: ({item}: {item: string}) => `Usunąć ${item}?`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 7df01e137152..8d32976dc8c6 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4035,6 +4035,7 @@ ${amount} para ${merchant} - ${date}`, outOfRange: ({min, max}: {min: number; max: number}) => `Insira um valor entre ${min} e ${max}`, tooFewItems: ({min}: {min: number}) => `Adicione pelo menos ${min}`, tooManyItems: ({max}: {max: number}) => `Adicione no máximo ${max}`, + itemError: ({item, error}: {item: string; error: string}) => `${item}: ${error}`, }, addItem: ({item}: {item: string}) => `Adicionar ${item}`, removeItemTitle: ({item}: {item: string}) => `Remover ${item}?`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0b2a1edd4f79..1cf987ea4544 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3922,6 +3922,7 @@ ${amount},商户:${merchant} - 日期:${date}`, outOfRange: ({min, max}: {min: number; max: number}) => `请输入 ${min} 到 ${max} 之间的值`, tooFewItems: ({min}: {min: number}) => `至少添加 ${min} 项`, tooManyItems: ({max}: {max: number}) => `最多添加 ${max} 项`, + itemError: ({item, error}: {item: string; error: string}) => `${item}:${error}`, }, addItem: ({item}: {item: string}) => `添加${item}`, removeItemTitle: ({item}: {item: string}) => `移除${item}?`, diff --git a/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts b/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts index e53d2bbeaeaf..e7c3219ff6f3 100644 --- a/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts +++ b/tests/unit/components/DynamicForm/getDynamicFieldErrorsTest.ts @@ -379,6 +379,7 @@ describe('getDynamicFieldErrors', () => { key: 'directors', type: 'list', required: true, + itemLabel: 'director', minItems: 2, itemFields: [ {key: 'firstName', type: 'text', required: true}, @@ -390,9 +391,10 @@ describe('getDynamicFieldErrors', () => { const emptyErrors = getDynamicFieldErrors([directors], {directors: []}, translateLocal); const shortErrors = getDynamicFieldErrors([directors], {directors: [{id: 'jane', firstName: ''}]}, translateLocal); - // Then the empty list is required, and the short list shows both the size and the entry's first problem, its missing name + // Then the empty list is required, and the short list shows both the size and the entry's first problem, its missing name, under the item noun since the entry has no name to show + const missingNameError = translateLocal('dynamicForm.error.itemError', {item: 'director', error: translateLocal('common.error.fieldRequired')}); expect(emptyErrors).toEqual({directors: translateLocal('common.error.fieldRequired')}); - expect(shortErrors).toEqual({directors: `${translateLocal('dynamicForm.error.tooFewItems', {min: 2})}\n${translateLocal('common.error.fieldRequired')}`}); + expect(shortErrors).toEqual({directors: `${translateLocal('dynamicForm.error.tooFewItems', {min: 2})}\n${missingNameError}`}); }); it('asks again for a sensitive entry answer the memory no longer holds', () => { @@ -412,8 +414,8 @@ describe('getDynamicFieldErrors', () => { const keptErrors = getDynamicFieldErrors([directors], {...draftValues, [getListItemSensitiveKey('directors', 'jane', 'ssn')]: '123456789'}, translateLocal); const lostErrors = getDynamicFieldErrors([directors], draftValues, translateLocal); - // Then the list passes while the SSN is kept, and asks for it once it is lost, so a director is never submitted without one + // Then the list passes while the SSN is kept, and asks for it once it is lost, naming Jane so the user knows which entry to open, so a director is never submitted without one expect(keptErrors).toEqual({}); - expect(lostErrors).toEqual({directors: translateLocal('common.error.fieldRequired')}); + expect(lostErrors).toEqual({directors: translateLocal('dynamicForm.error.itemError', {item: 'Jane', error: translateLocal('common.error.fieldRequired')})}); }); }); From 4e3d79fd89fc7ec3a0ff5d35669630e9665b4ea5 Mon Sep 17 00:00:00 2001 From: "Maruf Sharifi (via MelvinBot)" Date: Thu, 15 Oct 2026 06:45:09 +0000 Subject: [PATCH 733/781] Drop LHN recheck logic now handled by getReportAttributesUpdates on main Co-authored-by: Maruf Sharifi --- src/hooks/useSidebarOrderedReports.tsx | 22 ------------- tests/unit/useSidebarOrderedReportsTest.tsx | 34 ++------------------- 2 files changed, 3 insertions(+), 53 deletions(-) diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index a2f168aba8a3..80dd9483a600 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -8,7 +8,6 @@ import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; @@ -232,26 +231,6 @@ function SidebarOrderedReportsContextProvider({ // Rather than falling through to a full scan of all reports, recheck only the already-displayed reports. let effectiveUpdatedReports = updatedReports.length === 0 && hasCachedReports ? Object.keys(currentReportsToDisplay) : updatedReports; - // A report can be filtered out before its derived attributes are ready (e.g. a new DM that arrives before its - // expense preview). It is then not displayed, so the fallback above never rechecks it. Also recheck reports whose - // visibility-related attributes changed. Compare fields, not references: derived writes recreate every entry, - // so a reference diff would bring back a full scan. Skip the first hydration for the same reason. - if (hasCachedReports && reportAttributes !== prevReportAttributes && !isEmptyObject(prevReportAttributes)) { - const reportsToUpdate = new Set(effectiveUpdatedReports); - for (const [reportID, attributes] of Object.entries(reportAttributes ?? {})) { - const prevAttributes = prevReportAttributes[reportID]; - if ( - !prevAttributes || - attributes.requiresAttention !== prevAttributes.requiresAttention || - attributes.isEmpty !== prevAttributes.isEmpty || - attributes.brickRoadStatus !== prevAttributes.brickRoadStatus - ) { - reportsToUpdate.add(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - } - } - effectiveUpdatedReports = Array.from(reportsToUpdate); - } - // When guide personal details hydrate after the reports collection, guideAccountIDs changes but // getUpdatedReports() returns no report keys. Re-evaluate all reports so domain rooms previously // filtered out can appear in the LHN. @@ -311,7 +290,6 @@ function SidebarOrderedReportsContextProvider({ transactionViolations, reportNameValuePairs, reportAttributes, - prevReportAttributes, reportsDrafts, isOffline, clearCacheDummyCounter, diff --git a/tests/unit/useSidebarOrderedReportsTest.tsx b/tests/unit/useSidebarOrderedReportsTest.tsx index 6818a6d1a926..006d9540b713 100644 --- a/tests/unit/useSidebarOrderedReportsTest.tsx +++ b/tests/unit/useSidebarOrderedReportsTest.tsx @@ -598,37 +598,9 @@ describe('useSidebarOrderedReports', () => { }); await waitForBatchedUpdatesWithAct(); - // Then the DM is rechecked, so it can be added to the LHN without a full rebuild, and the displayed report is still - // rechecked with the new attributes by the existing fallback - expect(getUpdatedReportsKeys()).toEqual(expect.arrayContaining([`${ONYXKEYS.COLLECTION.REPORT}${newDMReportID}`, `${ONYXKEYS.COLLECTION.REPORT}${displayedReportID}`])); - }); - - it('should not recheck a report when its attributes are recreated with the same visibility fields', async () => { - // Given the DM already has attributes that keep it out of the LHN - await renderWithExcludedReport(); - await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - reports: {[displayedReportID]: createReportAttributes(), [newDMReportID]: createReportAttributes({isEmpty: true})}, - locale: 'en', - }); - }); - await waitForBatchedUpdatesWithAct(); - mockSidebarUtils.updateReportsToDisplayInLHN.mockClear(); - - // When the derived value is written again with new objects but the same visibility fields - await act(async () => { - await Onyx.set(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - reports: { - [displayedReportID]: createReportAttributes({reportName: 'Renamed'}), - [newDMReportID]: createReportAttributes({isEmpty: true, reportName: 'Renamed'}), - }, - locale: 'en', - }); - }); - await waitForBatchedUpdatesWithAct(); - - // Then the hidden DM is not rechecked, which keeps reportAttributes writes from triggering a full scan - expect(getUpdatedReportsKeys()).not.toContain(`${ONYXKEYS.COLLECTION.REPORT}${newDMReportID}`); + // Then only the DM is rechecked, so it can be added to the LHN without a full rebuild. The displayed report's + // attributes did not change, so it is not rechecked + expect(getUpdatedReportsKeys()).toEqual([`${ONYXKEYS.COLLECTION.REPORT}${newDMReportID}`]); }); }); }); From cdbdd1b387965e315be49793d55eb77aad65a174 Mon Sep 17 00:00:00 2001 From: "nkdengineer (via MelvinBot)" Date: Thu, 15 Oct 2026 08:21:17 +0000 Subject: [PATCH 734/781] Add getApprovalWorkflow, getDefaultApprover, and getRuleApprovers tests and group approval and payment tests Co-authored-by: nkdengineer --- tests/unit/PolicyUtilsTest.ts | 1793 ++++++++++++++++++--------------- 1 file changed, 977 insertions(+), 816 deletions(-) diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 844b6a98fa99..32fcca369e68 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -28,11 +28,13 @@ import { getActivePoliciesWithExpenseChatAndPerDiemEnabled, getAllTaxRates, getAllTaxRatesNamesAndValues, + getApprovalWorkflow, getConnectedIntegration, getConnectionExporters, getCurrentTaxID, getCustomUnitsForDuplication, getDefaultChatEnabledPolicy, + getDefaultApprover, getDefaultChatEnabledPolicySelection, getDefaultTimeTrackingRate, getDefaultWorkspacePlanType, @@ -63,6 +65,7 @@ import { getReimbursementChoice, isPolicyPayer, getReimburserEmail, + getRuleApprovers, getSubmitReportManagerAccountID, getSubmitToAccountID, getSubmitToEmail, @@ -1349,206 +1352,264 @@ describe('PolicyUtils', () => { }); }); - describe('getSubmitToAccountID', () => { + describe('shouldShowPolicy', () => { beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + global.fetch = TestHelper.getGlobalFetchMock(); + return Onyx.clear().then(waitForBatchedUpdates); }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); + it('should return false', () => { + // Given an archived paid policy. + const policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: '', + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The empty role deliberately models malformed persisted policy data. + const result = shouldShowPolicy(policy as unknown as Parameters[0], false, CARLOS_EMAIL); + // The result should be false since it is an archived paid policy. + expect(result).toBe(false); }); - describe('Has no rule approver', () => { - it('should return the policy approver/owner if the policy use the basic workflow', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(ownerAccountID); + it('should return true', () => { + // Given a paid policy. + const policy = createMock>({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null}); + const result = shouldShowPolicy(policy, false, CARLOS_EMAIL); + // The result should be true, since it is an active paid policy. + expect(result).toBe(true); + }); + it('should return false', () => { + // Given a control workspace which is pending delete. + const policy = createMock>({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }); - it('should return the policy approver/owner if the policy use the optional workflow', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(ownerAccountID); + const result = shouldShowPolicy(policy, false, CARLOS_EMAIL); + // The result should be false since it is a policy which is pending deletion. + expect(result).toEqual(false); + }); + it('hides an archived policy by default', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null, archivedDate: '2024-01-01'}; + const result = shouldShowPolicy(policy as OnyxEntry, false, CARLOS_EMAIL); + expect(result).toBe(false); + }); + it('shows an archived policy when includeArchivedPolicy is true', () => { + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null, archivedDate: '2024-01-01'}; + const result = shouldShowPolicy(policy as OnyxEntry, false, CARLOS_EMAIL, true); + expect(result).toBe(true); + }); + }); + + describe('approvals', () => { + describe('getApprovalWorkflow', () => { + it('should return the optional approval mode for a personal policy', () => { + // Given a personal policy that has the advanced approval mode stored on it + const policy = createMock({type: CONST.POLICY.TYPE.PERSONAL, approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED}); + + // When the approval workflow is read + // Then it is optional, because personal policies never require approval + expect(getApprovalWorkflow(policy)).toBe(CONST.POLICY.APPROVAL_MODE.OPTIONAL); }); - it('should return the employee submitsTo if the policy use the advance workflow', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - employeeList, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(adminAccountID); + + it('should return the approval mode set on a group policy', () => { + // Given a group policy that uses the basic approval mode + const policy = createMock({type: CONST.POLICY.TYPE.TEAM, approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC}); + + // When the approval workflow is read + // Then the policy's own approval mode is returned + expect(getApprovalWorkflow(policy)).toBe(CONST.POLICY.APPROVAL_MODE.BASIC); + }); + + it('should default to the advanced approval mode when a group policy has no approval mode', () => { + // Given a group policy without an approval mode + const policy = createMock({type: CONST.POLICY.TYPE.CORPORATE}); + + // When the approval workflow is read + // Then it falls back to advanced approvals + expect(getApprovalWorkflow(policy)).toBe(CONST.POLICY.APPROVAL_MODE.ADVANCED); + }); + + it('should default to the advanced approval mode when the policy is undefined', () => { + // Given no policy + // When the approval workflow is read + // Then it falls back to advanced approvals + expect(getApprovalWorkflow(undefined)).toBe(CONST.POLICY.APPROVAL_MODE.ADVANCED); }); }); - describe('Has category/tag approver', () => { - it('should return the first category approver if has any transaction category match with category approver rule', async () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.CORPORATE, - employeeList, - rules, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction1: Transaction = { - ...createRandomTransaction(0), - category: 'cat1', - reportID: expenseReport.reportID, - }; - const transaction2: Transaction = { - ...createRandomTransaction(1), - category: '', - reportID: expenseReport.reportID, - }; - const transactionData: TransactionCollectionDataSet = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, - }; - await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(categoryApprover1AccountID); + + describe('getDefaultApprover', () => { + it('should return the policy approver when one is set', () => { + // Given a policy whose approver is someone other than the owner + const policy = createMock({approver: approverEmail, owner: 'owner@test.com'}); + + // When the default approver is read + // Then the approver wins over the owner + expect(getDefaultApprover(policy)).toBe(approverEmail); + }); + + it('should fall back to the policy owner when there is no approver', () => { + // Given a policy with an owner but no approver + const policy = createMock({owner: 'owner@test.com'}); + + // When the default approver is read + // Then the owner is used + expect(getDefaultApprover(policy)).toBe('owner@test.com'); + }); + + it('should fall back to the policy owner when the approver is an empty string', () => { + // Given a policy whose approver was cleared to an empty string + const policy = createMock({approver: '', owner: 'owner@test.com'}); + + // When the default approver is read + // Then the empty approver is skipped and the owner is used + expect(getDefaultApprover(policy)).toBe('owner@test.com'); + }); + + it('should return an empty string when the policy has no approver or owner', () => { + // Given a policy with neither an approver nor an owner, and no policy at all + const policy = createMock({}); + + // When the default approver is read + // Then there is no one to fall back to + expect(getDefaultApprover(policy)).toBe(''); + expect(getDefaultApprover(undefined)).toBe(''); + }); + }); + + describe('getRuleApprovers', () => { + const policy = createMock({type: CONST.POLICY.TYPE.CORPORATE, rules}); + const expenseReport: Report = { + ...createRandomReport(0, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + }; + + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); }); - it('should return default approver if rule approver is submitter and prevent self approval is enabled', async () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.CORPORATE, - employeeList, - rules, - preventSelfApproval: true, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction: Transaction = { - ...createRandomTransaction(0), - category: 'cat1', - reportID: expenseReport.reportID, - tag: '', - }; + it('should return no approvers when the report has no transactions', () => { + // Given an expense report with no transactions in Onyx + // When the rule approvers are read + // Then there is no transaction to match a rule against + expect(getRuleApprovers(policy, expenseReport)).toEqual([]); + }); + + it('should return no approvers when the policy has no approval rules', async () => { + // Given a report whose transaction has a category and tag, on a policy without approval rules + const transaction: Transaction = {...createRandomTransaction(0), category: 'cat1', tag: 'tag1', reportID: expenseReport.reportID}; await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email, undefined)).toBe(adminAccountID); + + // When the rule approvers are read + // Then nothing matches, because there are no rules + expect(getRuleApprovers(createMock({type: CONST.POLICY.TYPE.CORPORATE}), expenseReport)).toEqual([]); }); - it('should return the category approver of the first transaction sorted by created if we have many transaction categories match with the category approver rule', async () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.CORPORATE, - employeeList, - rules, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction1: Transaction = { - ...createRandomTransaction(0), - category: 'cat1', - created: testDate, - reportID: expenseReport.reportID, - }; - const transaction2: Transaction = { - ...createRandomTransaction(1), - category: 'cat2', - created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), - reportID: expenseReport.reportID, - }; + + it('should put category approvers before tag approvers', async () => { + // Given an earlier transaction that only matches a tag rule and a later one that only matches a category rule + const tagTransaction: Transaction = {...createRandomTransaction(0), category: '', tag: 'tag1', created: '2025-01-01', reportID: expenseReport.reportID}; + const categoryTransaction: Transaction = {...createRandomTransaction(1), category: 'cat1', tag: '', created: '2025-01-02', reportID: expenseReport.reportID}; const transactionData: TransactionCollectionDataSet = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${tagTransaction.transactionID}`]: tagTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${categoryTransaction.transactionID}`]: categoryTransaction, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(categoryApprover2AccountID); + + // When the rule approvers are read + // Then the category approver comes first even though its transaction is newer, because category approvers are prioritized + expect(getRuleApprovers(policy, expenseReport)).toEqual(['categoryapprover1@test.com', 'tagapprover1@test.com']); }); - it('should return the first rule approver who is not the current submitter', async () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - type: CONST.POLICY.TYPE.CORPORATE, - employeeList, - rules, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction1: Transaction = { - ...createRandomTransaction(0), - category: 'cat1', - reportID: expenseReport.reportID, - tag: '', - created: DateUtils.subtractMillisecondsFromDateTime(testDate, 2), - }; - const transaction2: Transaction = { - ...createRandomTransaction(1), - category: 'cat3', - reportID: expenseReport.reportID, - tag: '', - created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), + it('should order approvers of the same kind by transaction created date', async () => { + // Given two transactions matching different category rules, where the cat2 transaction is the newer one + const newerTransaction: Transaction = {...createRandomTransaction(0), category: 'cat2', tag: '', created: '2025-01-02', reportID: expenseReport.reportID}; + const olderTransaction: Transaction = {...createRandomTransaction(1), category: 'cat1', tag: '', created: '2025-01-01', reportID: expenseReport.reportID}; + const transactionData: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${newerTransaction.transactionID}`]: newerTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${olderTransaction.transactionID}`]: olderTransaction, }; + await Onyx.multiSet({...transactionData}); - const transaction3: Transaction = { - ...createRandomTransaction(2), - category: '', - reportID: expenseReport.reportID, - tag: 'tag1', - created: testDate, - }; + // When the rule approvers are read + // Then the approver of the older transaction comes first + expect(getRuleApprovers(policy, expenseReport)).toEqual(['categoryapprover1@test.com', 'categoryapprover2@test.com']); + }); + it('should return each approver only once', async () => { + // Given two transactions whose categories (cat1 and cat3) both route to the same approver + const transaction1: Transaction = {...createRandomTransaction(0), category: 'cat1', tag: '', created: '2025-01-01', reportID: expenseReport.reportID}; + const transaction2: Transaction = {...createRandomTransaction(1), category: 'cat3', tag: '', created: '2025-01-02', reportID: expenseReport.reportID}; const transactionData: TransactionCollectionDataSet = { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction3.transactionID}`]: transaction3, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email, undefined)).toBe(tagApprover1AccountID); + // When the rule approvers are read + // Then the shared approver is listed once + expect(getRuleApprovers(policy, expenseReport)).toEqual(['categoryapprover1@test.com']); + }); + }); + + describe('getSubmitToAccountID', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + describe('Has no rule approver', () => { + it('should return the policy approver/owner if the policy use the basic workflow', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(ownerAccountID); + }); + it('should return the policy approver/owner if the policy use the optional workflow', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(ownerAccountID); + }); + it('should return the employee submitsTo if the policy use the advance workflow', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(adminAccountID); + }); }); - describe('Has no transaction match with the category approver rule', () => { - it('should return the first tag approver if has any transaction tag match with with the tag approver rule ', async () => { + describe('Has category/tag approver', () => { + it('should return the first category approver if has any transaction category match with category approver rule', async () => { const policy: Policy = { ...createRandomPolicy(0), approver: 'owner@test.com', @@ -1565,16 +1626,12 @@ describe('PolicyUtils', () => { }; const transaction1: Transaction = { ...createRandomTransaction(0), - category: '', - tag: 'tag1', - created: testDate, + category: 'cat1', reportID: expenseReport.reportID, }; const transaction2: Transaction = { ...createRandomTransaction(1), category: '', - tag: '', - created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), reportID: expenseReport.reportID, }; const transactionData: TransactionCollectionDataSet = { @@ -1582,9 +1639,35 @@ describe('PolicyUtils', () => { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(tagApprover1AccountID); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(categoryApprover1AccountID); + }); + it('should return default approver if rule approver is submitter and prevent self approval is enabled', async () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.CORPORATE, + employeeList, + rules, + preventSelfApproval: true, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction: Transaction = { + ...createRandomTransaction(0), + category: 'cat1', + reportID: expenseReport.reportID, + tag: '', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email, undefined)).toBe(adminAccountID); }); - it('should return the tag approver of the first transaction sorted by created if we have many transaction tags match with the tag approver rule', async () => { + it('should return the category approver of the first transaction sorted by created if we have many transaction categories match with the category approver rule', async () => { const policy: Policy = { ...createRandomPolicy(0), approver: 'owner@test.com', @@ -1601,15 +1684,13 @@ describe('PolicyUtils', () => { }; const transaction1: Transaction = { ...createRandomTransaction(0), - category: '', - tag: 'tag1', + category: 'cat1', created: testDate, reportID: expenseReport.reportID, }; const transaction2: Transaction = { ...createRandomTransaction(1), - category: '', - tag: 'tag2', + category: 'cat2', created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), reportID: expenseReport.reportID, }; @@ -1618,499 +1699,577 @@ describe('PolicyUtils', () => { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, }; await Onyx.multiSet({...transactionData}); - expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(tagApprover2AccountID); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(categoryApprover2AccountID); }); - }); - }); - }); - describe('getSubmitReportManagerAccountID', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - it('should return undefined for an advanced policy where the submitter is missing from the employee list, even if the report has a valid managerID', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - // The submitter isn't in the employee list, which is the case for reports migrated from Expensify Classic. - employeeList: {}, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - // The stale Classic-era manager stamped on the migrated report. - managerID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBeUndefined(); - }); - it('should return the known approver accountID when the policy route is reliable', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - employeeList, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - managerID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(adminAccountID); + it('should return the first rule approver who is not the current submitter', async () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.CORPORATE, + employeeList, + rules, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction1: Transaction = { + ...createRandomTransaction(0), + category: 'cat1', + reportID: expenseReport.reportID, + tag: '', + created: DateUtils.subtractMillisecondsFromDateTime(testDate, 2), + }; + + const transaction2: Transaction = { + ...createRandomTransaction(1), + category: 'cat3', + reportID: expenseReport.reportID, + tag: '', + created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), + }; + + const transaction3: Transaction = { + ...createRandomTransaction(2), + category: '', + reportID: expenseReport.reportID, + tag: 'tag1', + created: testDate, + }; + + const transactionData: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction3.transactionID}`]: transaction3, + }; + await Onyx.multiSet({...transactionData}); + + expect(getSubmitToAccountID(policy, expenseReport, categoryApprover1Email, undefined)).toBe(tagApprover1AccountID); + }); + describe('Has no transaction match with the category approver rule', () => { + it('should return the first tag approver if has any transaction tag match with with the tag approver rule ', async () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.CORPORATE, + employeeList, + rules, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction1: Transaction = { + ...createRandomTransaction(0), + category: '', + tag: 'tag1', + created: testDate, + reportID: expenseReport.reportID, + }; + const transaction2: Transaction = { + ...createRandomTransaction(1), + category: '', + tag: '', + created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), + reportID: expenseReport.reportID, + }; + const transactionData: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + }; + await Onyx.multiSet({...transactionData}); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(tagApprover1AccountID); + }); + it('should return the tag approver of the first transaction sorted by created if we have many transaction tags match with the tag approver rule', async () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + type: CONST.POLICY.TYPE.CORPORATE, + employeeList, + rules, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction1: Transaction = { + ...createRandomTransaction(0), + category: '', + tag: 'tag1', + created: testDate, + reportID: expenseReport.reportID, + }; + const transaction2: Transaction = { + ...createRandomTransaction(1), + category: '', + tag: 'tag2', + created: DateUtils.subtractMillisecondsFromDateTime(testDate, 1), + reportID: expenseReport.reportID, + }; + const transactionData: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + }; + await Onyx.multiSet({...transactionData}); + expect(getSubmitToAccountID(policy, expenseReport, employeeEmail, undefined)).toBe(tagApprover2AccountID); + }); + }); + }); }); - it('should return undefined instead of a generated accountID when the approver is missing from personal details', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - employeeList: { - [employeeEmail]: { - email: employeeEmail, - role: 'user', - submitsTo: 'unknown@test.com', - }, - // The approver is still a policy member, so the submit-to route stays put instead of falling back to the default approver. - 'unknown@test.com': { - email: 'unknown@test.com', - role: 'user', + describe('getSubmitReportManagerAccountID', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + it('should return undefined for an advanced policy where the submitter is missing from the employee list, even if the report has a valid managerID', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + // The submitter isn't in the employee list, which is the case for reports migrated from Expensify Classic. + employeeList: {}, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + // The stale Classic-era manager stamped on the migrated report. + managerID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBeUndefined(); + }); + it('should return the known approver accountID when the policy route is reliable', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + managerID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(adminAccountID); + }); + it('should return undefined instead of a generated accountID when the approver is missing from personal details', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList: { + [employeeEmail]: { + email: employeeEmail, + role: 'user', + submitsTo: 'unknown@test.com', + }, + // The approver is still a policy member, so the submit-to route stays put instead of falling back to the default approver. + 'unknown@test.com': { + email: 'unknown@test.com', + role: 'user', + }, }, - }, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - managerID: categoryApprover1AccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBeUndefined(); - }); - }); - describe('shouldShowPolicy', () => { - beforeEach(() => { - global.fetch = TestHelper.getGlobalFetchMock(); - return Onyx.clear().then(waitForBatchedUpdates); - }); - it('should return false', () => { - // Given an archived paid policy. - const policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: '', - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The empty role deliberately models malformed persisted policy data. - const result = shouldShowPolicy(policy as unknown as Parameters[0], false, CARLOS_EMAIL); - // The result should be false since it is an archived paid policy. - expect(result).toBe(false); - }); - it('should return true', () => { - // Given a paid policy. - const policy = createMock>({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null}); - const result = shouldShowPolicy(policy, false, CARLOS_EMAIL); - // The result should be true, since it is an active paid policy. - expect(result).toBe(true); - }); - it('should return false', () => { - // Given a control workspace which is pending delete. - const policy = createMock>({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + managerID: categoryApprover1AccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBeUndefined(); }); - const result = shouldShowPolicy(policy, false, CARLOS_EMAIL); - // The result should be false since it is a policy which is pending deletion. - expect(result).toEqual(false); - }); - it('hides an archived policy by default', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null, archivedDate: '2024-01-01'}; - const result = shouldShowPolicy(policy as OnyxEntry, false, CARLOS_EMAIL); - expect(result).toBe(false); }); - it('shows an archived policy when includeArchivedPolicy is true', () => { - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), pendingAction: null, archivedDate: '2024-01-01'}; - const result = shouldShowPolicy(policy as OnyxEntry, false, CARLOS_EMAIL, true); - expect(result).toBe(true); - }); - }); - describe('getManagerAccountID', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); + describe('getManagerAccountID', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); - it('should return default approver for personal workspaces', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.PERSONAL, - approver: categoryApprover1Email, - }; - const result = getManagerAccountID(policy, '', undefined); + it('should return default approver for personal workspaces', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.PERSONAL, + approver: categoryApprover1Email, + }; + const result = getManagerAccountID(policy, '', undefined); - expect(result).toBe(categoryApprover1AccountID); - }); + expect(result).toBe(categoryApprover1AccountID); + }); - it('should return -1 if there is no employee or default approver', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: undefined, - approver: undefined, - owner: '', - }; + it('should return -1 if there is no employee or default approver', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: undefined, + approver: undefined, + owner: '', + }; - const result = getManagerAccountID(policy, '', undefined); + const result = getManagerAccountID(policy, '', undefined); - expect(result).toBe(-1); - }); + expect(result).toBe(-1); + }); - it('should return submitsTo account ID', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: undefined, - employeeList: { - [employeeEmail]: { - email: employeeEmail, - submitsTo: adminEmail, + it('should return submitsTo account ID', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: undefined, + employeeList: { + [employeeEmail]: { + email: employeeEmail, + submitsTo: adminEmail, + }, }, - }, - }; + }; - const result = getManagerAccountID(policy, employeeEmail, undefined); + const result = getManagerAccountID(policy, employeeEmail, undefined); - expect(result).toBe(adminAccountID); - }); + expect(result).toBe(adminAccountID); + }); - it('should return submitsTo email from workspace approval config', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: undefined, - employeeList: { - [adminEmail]: { - email: adminEmail, - }, - [employeeEmail]: { - email: employeeEmail, - submitsTo: adminEmail, + it('should return submitsTo email from workspace approval config', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: undefined, + employeeList: { + [adminEmail]: { + email: adminEmail, + }, + [employeeEmail]: { + email: employeeEmail, + submitsTo: adminEmail, + }, }, - }, - }; - const report: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - }; + }; + const report: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + }; - expect(getSubmitToEmail(policy, report, employeeEmail, undefined)).toBe(adminEmail); - }); + expect(getSubmitToEmail(policy, report, employeeEmail, undefined)).toBe(adminEmail); + }); - it('should return the default approver', () => { - const policy: Policy = { - ...createRandomPolicy(0), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: undefined, - approver: categoryApprover1Email, - }; + it('should return the default approver', () => { + const policy: Policy = { + ...createRandomPolicy(0), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: undefined, + approver: categoryApprover1Email, + }; - const result = getManagerAccountID(policy, '', undefined); + const result = getManagerAccountID(policy, '', undefined); - expect(result).toBe(categoryApprover1AccountID); + expect(result).toBe(categoryApprover1AccountID); + }); }); - }); - describe('approval workflow rules', () => { - const policyID = 'RULES_POLICY_1'; - const submitFilter = {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employeeEmail]}; + describe('approval workflow rules', () => { + const policyID = 'RULES_POLICY_1'; + const submitFilter = {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employeeEmail]}; - const buildRule = (rule: ApprovalWorkflowRule): Rule => ({...rule, scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID}); + const buildRule = (rule: ApprovalWorkflowRule): Rule => ({...rule, scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID}); - const submitRule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, - filters: submitFilter, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, - }; + const submitRule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, + filters: submitFilter, + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, + }; - // After the admin approves, an under-limit report continues to the approver and an over-limit one is - // escalated to the category approver instead. - const underLimitRule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: submitFilter, - right: { + // After the admin approves, an under-limit report continues to the approver and an over-limit one is + // escalated to the category approver instead. + const underLimitRule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, + filters: { operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: adminEmail}, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}, + left: submitFilter, + right: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: adminEmail}, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}, + }, }, - }, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: approverEmail}}, - }; - const overLimitRule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: submitFilter, - right: { + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: approverEmail}}, + }; + const overLimitRule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, + filters: { operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: adminEmail}, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}, + left: submitFilter, + right: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: adminEmail}, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}, + }, }, - }, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: categoryApprover1Email}}, - }; - const terminalRule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: submitFilter, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: approverEmail}, - }, - actions: {'1': {name: CONST.RULES.ACTIONS.APPROVE_REPORT}}, - }; - - // A workspace whose employeeList still points somewhere else, so a rule-driven answer is distinguishable - // from the legacy fallback. - const policy: Policy = { - ...createRandomPolicy(0), - id: policyID, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - approver: categoryApprover1Email, - employeeList: { - [employeeEmail]: {email: employeeEmail, submitsTo: categoryApprover1Email}, - }, - }; - - const rulesCollection: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.RULE}1`]: buildRule(submitRule), - [`${ONYXKEYS.COLLECTION.RULE}2`]: buildRule(underLimitRule), - [`${ONYXKEYS.COLLECTION.RULE}3`]: buildRule(overLimitRule), - [`${ONYXKEYS.COLLECTION.RULE}4`]: buildRule(terminalRule), - }; - - beforeEach(async () => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}1` as const, buildRule(submitRule)); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}2` as const, buildRule(underLimitRule)); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}3` as const, buildRule(overLimitRule)); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}4` as const, buildRule(terminalRule)); - await waitForBatchedUpdatesWithAct(); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: categoryApprover1Email}}, + }; + const terminalRule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_APPROVE}, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: submitFilter, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: approverEmail}, + }, + actions: {'1': {name: CONST.RULES.ACTIONS.APPROVE_REPORT}}, + }; - describe('evaluateApprovalWorkflowRule', () => { - it('matches the submit rule for a submitter it covers', () => { - expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(true); - }); + // A workspace whose employeeList still points somewhere else, so a rule-driven answer is distinguishable + // from the legacy fallback. + const policy: Policy = { + ...createRandomPolicy(0), + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: categoryApprover1Email, + employeeList: { + [employeeEmail]: {email: employeeEmail, submitsTo: categoryApprover1Email}, + }, + }; - it('does not match the submit rule for another submitter', () => { - expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: adminEmail, reportTotal: 0})).toBe(false); - }); + const rulesCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.RULE}1`]: buildRule(submitRule), + [`${ONYXKEYS.COLLECTION.RULE}2`]: buildRule(underLimitRule), + [`${ONYXKEYS.COLLECTION.RULE}3`]: buildRule(overLimitRule), + [`${ONYXKEYS.COLLECTION.RULE}4`]: buildRule(terminalRule), + }; - it('does not match a rule gated on the current approver when the report has none', () => { - expect(evaluateApprovalWorkflowRule(underLimitRule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); + beforeEach(async () => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}1` as const, buildRule(submitRule)); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}2` as const, buildRule(underLimitRule)); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}3` as const, buildRule(overLimitRule)); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}4` as const, buildRule(terminalRule)); + await waitForBatchedUpdatesWithAct(); }); - - it('splits on the report total, comparing the absolute amount', () => { - const context = {submitterEmail: employeeEmail, currentApproverEmail: adminEmail}; - expect(evaluateApprovalWorkflowRule(underLimitRule, {...context, reportTotal: -5000})).toBe(true); - expect(evaluateApprovalWorkflowRule(overLimitRule, {...context, reportTotal: -5000})).toBe(false); - expect(evaluateApprovalWorkflowRule(underLimitRule, {...context, reportTotal: -20000})).toBe(false); - expect(evaluateApprovalWorkflowRule(overLimitRule, {...context, reportTotal: -20000})).toBe(true); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); }); - const buildAmountRule = (operator: ValueOf, right: number): ApprovalWorkflowRule => ({ - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, - filters: {operator, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right}, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, - }); + describe('evaluateApprovalWorkflowRule', () => { + it('matches the submit rule for a submitter it covers', () => { + expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(true); + }); - it.each([ - ['eq matches an exact amount', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 5000, -5000, true], - ['eq rejects a different amount', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 5000, -6000, false], - ['neq rejects an exact amount', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, 5000, -5000, false], - ['neq matches a different amount', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, 5000, -6000, true], - ['gt rejects an equal amount', CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, 5000, -5000, false], - ['gt matches a strictly greater amount', CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, 5000, -5001, true], - ['lte matches an equal amount', CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, 5000, -5000, true], - ['lte rejects a greater amount', CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, 5000, -5001, false], - ])('%s', (_description, operator, right, reportTotal, expected) => { - expect(evaluateApprovalWorkflowRule(buildAmountRule(operator, right), {submitterEmail: employeeEmail, reportTotal})).toBe(expected); - }); + it('does not match the submit rule for another submitter', () => { + expect(evaluateApprovalWorkflowRule(submitRule, {submitterEmail: adminEmail, reportTotal: 0})).toBe(false); + }); - it('does not match an amount filter whose right side is not numeric', () => { - const rule = buildAmountRule(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, Number('not-a-number')); - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); - }); + it('does not match a rule gated on the current approver when the report has none', () => { + expect(evaluateApprovalWorkflowRule(underLimitRule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); + }); - it('does not match an amount filter using an operator this client does not understand', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The unrecognized operator is the scenario under test (a future backend operator this client predates). - const rule = buildAmountRule('unsupportedOperator' as ValueOf, 5000); - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); - }); + it('splits on the report total, comparing the absolute amount', () => { + const context = {submitterEmail: employeeEmail, currentApproverEmail: adminEmail}; + expect(evaluateApprovalWorkflowRule(underLimitRule, {...context, reportTotal: -5000})).toBe(true); + expect(evaluateApprovalWorkflowRule(overLimitRule, {...context, reportTotal: -5000})).toBe(false); + expect(evaluateApprovalWorkflowRule(underLimitRule, {...context, reportTotal: -20000})).toBe(false); + expect(evaluateApprovalWorkflowRule(overLimitRule, {...context, reportTotal: -20000})).toBe(true); + }); - it('does not match a filter on a field this client does not understand', () => { - const rule: ApprovalWorkflowRule = { + const buildAmountRule = (operator: ValueOf, right: number): ApprovalWorkflowRule => ({ triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: 'unsupportedField', right: employeeEmail}, + filters: {operator, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right}, actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, - }; - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(false); - }); + }); - it('matches an OR filter when either side matches', () => { - const rule: ApprovalWorkflowRule = { - triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.OR, - left: submitFilter, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [adminEmail]}, - }, - actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, - }; + it.each([ + ['eq matches an exact amount', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 5000, -5000, true], + ['eq rejects a different amount', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 5000, -6000, false], + ['neq rejects an exact amount', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, 5000, -5000, false], + ['neq matches a different amount', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, 5000, -6000, true], + ['gt rejects an equal amount', CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, 5000, -5000, false], + ['gt matches a strictly greater amount', CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, 5000, -5001, true], + ['lte matches an equal amount', CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, 5000, -5000, true], + ['lte rejects a greater amount', CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, 5000, -5001, false], + ])('%s', (_description, operator, right, reportTotal, expected) => { + expect(evaluateApprovalWorkflowRule(buildAmountRule(operator, right), {submitterEmail: employeeEmail, reportTotal})).toBe(expected); + }); - // Only the left side matches this submitter, but OR only needs one side. - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(true); - // Neither side names this submitter. - expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: guideEmail, reportTotal: 0})).toBe(false); - }); - }); + it('does not match an amount filter whose right side is not numeric', () => { + const rule = buildAmountRule(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, Number('not-a-number')); + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); + }); - describe('getForwardsToFromRules', () => { - it('returns the first approver from the submit rule', () => { - expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, reportTotal: -5000}, rulesCollection)).toEqual({forwardsTo: adminEmail}); - }); + it('does not match an amount filter using an operator this client does not understand', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The unrecognized operator is the scenario under test (a future backend operator this client predates). + const rule = buildAmountRule('unsupportedOperator' as ValueOf, 5000); + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: -5000})).toBe(false); + }); - it('returns the next approver from the matching approve rule', () => { - expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: adminEmail, reportTotal: -5000}, rulesCollection)).toEqual({ - forwardsTo: approverEmail, + it('does not match a filter on a field this client does not understand', () => { + const rule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: 'unsupportedField', right: employeeEmail}, + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, + }; + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(false); }); - expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: adminEmail, reportTotal: -20000}, rulesCollection)).toEqual({ - forwardsTo: categoryApprover1Email, + + it('matches an OR filter when either side matches', () => { + const rule: ApprovalWorkflowRule = { + triggers: {'1': CONST.RULES.TRIGGERS.REPORT_SUBMIT}, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.OR, + left: submitFilter, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [adminEmail]}, + }, + actions: {'1': {name: CONST.RULES.ACTIONS.FORWARD_TO, approver: adminEmail}}, + }; + + // Only the left side matches this submitter, but OR only needs one side. + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: employeeEmail, reportTotal: 0})).toBe(true); + // Neither side names this submitter. + expect(evaluateApprovalWorkflowRule(rule, {submitterEmail: guideEmail, reportTotal: 0})).toBe(false); }); }); - it('matches with no forwardsTo when the rule finalizes the report', () => { - expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: approverEmail, reportTotal: -5000}, rulesCollection)).toEqual({ - forwardsTo: undefined, + describe('getForwardsToFromRules', () => { + it('returns the first approver from the submit rule', () => { + expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, reportTotal: -5000}, rulesCollection)).toEqual({forwardsTo: adminEmail}); }); - }); - it('returns undefined when no rule covers the submitter', () => { - expect(getForwardsToFromRules(policy, {submitterEmail: guideEmail, reportTotal: -5000}, rulesCollection)).toBeUndefined(); - }); + it('returns the next approver from the matching approve rule', () => { + expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: adminEmail, reportTotal: -5000}, rulesCollection)).toEqual({ + forwardsTo: approverEmail, + }); + expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: adminEmail, reportTotal: -20000}, rulesCollection)).toEqual({ + forwardsTo: categoryApprover1Email, + }); + }); + + it('matches with no forwardsTo when the rule finalizes the report', () => { + expect(getForwardsToFromRules(policy, {submitterEmail: employeeEmail, currentApproverEmail: approverEmail, reportTotal: -5000}, rulesCollection)).toEqual({ + forwardsTo: undefined, + }); + }); + + it('returns undefined when no rule covers the submitter', () => { + expect(getForwardsToFromRules(policy, {submitterEmail: guideEmail, reportTotal: -5000}, rulesCollection)).toBeUndefined(); + }); - it('ignores rules belonging to another workspace', () => { - expect(getForwardsToFromRules({...policy, id: 'OTHER_POLICY'}, {submitterEmail: employeeEmail, reportTotal: -5000}, rulesCollection)).toBeUndefined(); + it('ignores rules belonging to another workspace', () => { + expect(getForwardsToFromRules({...policy, id: 'OTHER_POLICY'}, {submitterEmail: employeeEmail, reportTotal: -5000}, rulesCollection)).toBeUndefined(); + }); }); - }); - it('getManagerAccountID prefers the rules over the employee submitsTo', () => { - expect(getManagerAccountID(policy, employeeEmail, rulesCollection)).toBe(adminAccountID); - }); + it('getManagerAccountID prefers the rules over the employee submitsTo', () => { + expect(getManagerAccountID(policy, employeeEmail, rulesCollection)).toBe(adminAccountID); + }); - it('getForwardsToAccount follows the rules for the next hop', () => { - expect(getForwardsToAccount(policy, adminEmail, -5000, rulesCollection, employeeEmail)).toBe(approverEmail); - expect(getForwardsToAccount(policy, adminEmail, -20000, rulesCollection, employeeEmail)).toBe(categoryApprover1Email); - expect(getForwardsToAccount(policy, approverEmail, -5000, rulesCollection, employeeEmail)).toBe(''); - }); + it('getForwardsToAccount follows the rules for the next hop', () => { + expect(getForwardsToAccount(policy, adminEmail, -5000, rulesCollection, employeeEmail)).toBe(approverEmail); + expect(getForwardsToAccount(policy, adminEmail, -20000, rulesCollection, employeeEmail)).toBe(categoryApprover1Email); + expect(getForwardsToAccount(policy, approverEmail, -5000, rulesCollection, employeeEmail)).toBe(''); + }); - it('getApprovalChain walks the whole rule chain', () => { - const report: Report = {...createRandomReport(0, undefined), policyID, ownerAccountID: employeeAccountID, total: -5000}; - expect(getApprovalChain(policy, report, employeeEmail, rulesCollection)).toEqual([adminEmail, approverEmail]); - }); + it('getApprovalChain walks the whole rule chain', () => { + const report: Report = {...createRandomReport(0, undefined), policyID, ownerAccountID: employeeAccountID, total: -5000}; + expect(getApprovalChain(policy, report, employeeEmail, rulesCollection)).toEqual([adminEmail, approverEmail]); + }); - it('getSubmitToEmail keeps the rules approver when that approver has no employeeList entry', () => { - const report: Report = {...createRandomReport(0, undefined), policyID, ownerAccountID: employeeAccountID, total: -5000}; - expect(getSubmitToEmail(policy, report, employeeEmail, rulesCollection, true)).toBe(adminEmail); + it('getSubmitToEmail keeps the rules approver when that approver has no employeeList entry', () => { + const report: Report = {...createRandomReport(0, undefined), policyID, ownerAccountID: employeeAccountID, total: -5000}; + expect(getSubmitToEmail(policy, report, employeeEmail, rulesCollection, true)).toBe(adminEmail); + }); }); - }); - describe('getSubmitReportManagerAccountID', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - it('should return the default approver if the employee submitsTo is no longer a policy member and the policy use the advance workflow', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - employeeList: { - 'owner@test.com': { - email: 'owner@test.com', - role: 'admin', - submitsTo: '', - }, - [employeeEmail]: { - email: employeeEmail, - role: 'user', - submitsTo: 'removed-approver@test.com', - }, - }, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(ownerAccountID); - }); - it('should keep the submitsTo approver that is not a policy member when the policy uses HR advanced (manager) mode', () => { - const policy: Policy = { - ...createRandomPolicy(0), - approver: 'owner@test.com', - owner: 'owner@test.com', - employeeList: { - 'owner@test.com': { - email: 'owner@test.com', - role: 'admin', - submitsTo: '', - }, - [employeeEmail]: { - email: employeeEmail, - role: 'user', - submitsTo: adminEmail, + describe('getSubmitReportManagerAccountID', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + it('should return the default approver if the employee submitsTo is no longer a policy member and the policy use the advance workflow', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList: { + 'owner@test.com': { + email: 'owner@test.com', + role: 'admin', + submitsTo: '', + }, + [employeeEmail]: { + email: employeeEmail, + role: 'user', + submitsTo: 'removed-approver@test.com', + }, }, - }, - connections: { - [CONST.POLICY.CONNECTIONS.NAME.GUSTO]: { - config: {approvalMode: CONST.GUSTO.APPROVAL_MODE.MANAGER, finalApprover: 'owner@test.com'}, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(ownerAccountID); + }); + it('should keep the submitsTo approver that is not a policy member when the policy uses HR advanced (manager) mode', () => { + const policy: Policy = { + ...createRandomPolicy(0), + approver: 'owner@test.com', + owner: 'owner@test.com', + employeeList: { + 'owner@test.com': { + email: 'owner@test.com', + role: 'admin', + submitsTo: '', + }, + [employeeEmail]: { + email: employeeEmail, + role: 'user', + submitsTo: adminEmail, + }, }, - } as Policy['connections'], - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - }; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - ownerAccountID: employeeAccountID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(adminAccountID); + connections: { + [CONST.POLICY.CONNECTIONS.NAME.GUSTO]: { + config: {approvalMode: CONST.GUSTO.APPROVAL_MODE.MANAGER, finalApprover: 'owner@test.com'}, + }, + } as Policy['connections'], + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + ownerAccountID: employeeAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(adminAccountID); + }); }); }); @@ -2998,270 +3157,272 @@ describe('PolicyUtils', () => { }); }); - describe('getReimburserEmail', () => { - it('should return undefined when reimbursement is disabled', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, - reimburser: 'reimburser@example.com', - achAccount: {reimburser: 'ach@example.com'}, - owner: 'owner@example.com', + describe('payments', () => { + describe('getReimburserEmail', () => { + it('should return undefined when reimbursement is disabled', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + reimburser: 'reimburser@example.com', + achAccount: {reimburser: 'ach@example.com'}, + owner: 'owner@example.com', + }); + expect(getReimburserEmail(policy)).toBeUndefined(); }); - expect(getReimburserEmail(policy)).toBeUndefined(); - }); - it('should resolve the payer for manual (indirect) reimbursement from achAccount.reimburser', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - achAccount: {reimburser: 'ach@example.com'}, - owner: 'owner@example.com', + it('should resolve the payer for manual (indirect) reimbursement from achAccount.reimburser', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + achAccount: {reimburser: 'ach@example.com'}, + owner: 'owner@example.com', + }); + expect(getReimburserEmail(policy)).toBe('ach@example.com'); }); - expect(getReimburserEmail(policy)).toBe('ach@example.com'); - }); - it('should prefer policy.reimburser over achAccount.reimburser', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - reimburser: 'reimburser@example.com', - achAccount: {reimburser: 'ach@example.com'}, + it('should prefer policy.reimburser over achAccount.reimburser', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: 'reimburser@example.com', + achAccount: {reimburser: 'ach@example.com'}, + }); + expect(getReimburserEmail(policy)).toBe('reimburser@example.com'); }); - expect(getReimburserEmail(policy)).toBe('reimburser@example.com'); - }); - it('should fall back to the owner for manual reimbursement when no reimburser is set', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - owner: 'owner@example.com', + it('should fall back to the owner for manual reimbursement when no reimburser is set', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + owner: 'owner@example.com', + }); + expect(getReimburserEmail(policy)).toBe('owner@example.com'); }); - expect(getReimburserEmail(policy)).toBe('owner@example.com'); - }); - it('should not fall back to the owner for auto reimbursement when no reimburser is set', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - owner: 'owner@example.com', + it('should not fall back to the owner for auto reimbursement when no reimburser is set', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + owner: 'owner@example.com', + }); + expect(getReimburserEmail(policy)).toBeUndefined(); }); - expect(getReimburserEmail(policy)).toBeUndefined(); - }); - it('should resolve the payer for deprecated manual reimbursement', () => { - // Given a workspace on Indirect reimbursement that reports the deprecated value and names no payer - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - owner: 'owner@example.com', + it('should resolve the payer for deprecated manual reimbursement', () => { + // Given a workspace on Indirect reimbursement that reports the deprecated value and names no payer + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + owner: 'owner@example.com', + }); + + // When the payer is resolved + const reimburserEmail = getReimburserEmail(policy); + + // Then it falls back to the owner, because the deprecated value means Indirect just like the plain one + expect(reimburserEmail).toBe('owner@example.com'); }); - // When the payer is resolved - const reimburserEmail = getReimburserEmail(policy); + it('should return undefined when deprecated reimbursement is disabled', () => { + // Given a workspace that reports the deprecated disabled value but still names a payer + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + reimburser: 'reimburser@example.com', + owner: 'owner@example.com', + }); - // Then it falls back to the owner, because the deprecated value means Indirect just like the plain one - expect(reimburserEmail).toBe('owner@example.com'); - }); + // When the payer is resolved + const reimburserEmail = getReimburserEmail(policy); - it('should return undefined when deprecated reimbursement is disabled', () => { - // Given a workspace that reports the deprecated disabled value but still names a payer - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, - reimburser: 'reimburser@example.com', - owner: 'owner@example.com', + // Then nobody is returned, because a disabled workspace has no payer no matter who is named on it + expect(reimburserEmail).toBeUndefined(); }); + }); - // When the payer is resolved - const reimburserEmail = getReimburserEmail(policy); + describe('getReimbursementChoice', () => { + it('should return undefined when there is no policy', () => { + // Given no policy, which happens while a workspace is still loading - // Then nobody is returned, because a disabled workspace has no payer no matter who is named on it - expect(reimburserEmail).toBeUndefined(); - }); - }); + // When the choice is resolved + const choice = getReimbursementChoice(undefined); - describe('getReimbursementChoice', () => { - it('should return undefined when there is no policy', () => { - // Given no policy, which happens while a workspace is still loading + // Then nothing is returned, so callers fall back to their own defaults instead of guessing a choice + expect(choice).toBeUndefined(); + }); - // When the choice is resolved - const choice = getReimbursementChoice(undefined); + it('should return undefined when the policy has no choice', () => { + // Given a workspace that reports no reimbursement choice at all + const policy = createMock({id: '1'}); - // Then nothing is returned, so callers fall back to their own defaults instead of guessing a choice - expect(choice).toBeUndefined(); - }); + // When the choice is resolved + const choice = getReimbursementChoice(policy); + + // Then nothing is returned, because an absent choice must stay absent rather than become a real value + expect(choice).toBeUndefined(); + }); - it('should return undefined when the policy has no choice', () => { - // Given a workspace that reports no reimbursement choice at all - const policy = createMock({id: '1'}); + it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL])( + 'should pass through the current value %s', + (choice) => { + // Given a workspace on one of the three values the app already understands + const policy = createMock({id: '1', reimbursementChoice: choice}); - // When the choice is resolved - const choice = getReimbursementChoice(policy); + // When the choice is resolved + const resolvedChoice = getReimbursementChoice(policy); - // Then nothing is returned, because an absent choice must stay absent rather than become a real value - expect(choice).toBeUndefined(); - }); + // Then it is returned untouched, so resolving cannot change the meaning of a workspace that was already correct + expect(resolvedChoice).toBe(choice); + }, + ); - it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL])( - 'should pass through the current value %s', - (choice) => { - // Given a workspace on one of the three values the app already understands - const policy = createMock({id: '1', reimbursementChoice: choice}); + it.each([ + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO], + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL], + ])('should resolve %s to %s', (deprecatedChoice, expectedChoice) => { + // Given a workspace that reports a deprecated value + const policy = createMock({id: '1', reimbursementChoice: deprecatedChoice}); // When the choice is resolved const resolvedChoice = getReimbursementChoice(policy); - // Then it is returned untouched, so resolving cannot change the meaning of a workspace that was already correct - expect(resolvedChoice).toBe(choice); - }, - ); + // Then it becomes the plain value it means, so every comparison downstream treats the workspace correctly + expect(resolvedChoice).toBe(expectedChoice); + }); - it.each([ - [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO], - [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL], - ])('should resolve %s to %s', (deprecatedChoice, expectedChoice) => { - // Given a workspace that reports a deprecated value - const policy = createMock({id: '1', reimbursementChoice: deprecatedChoice}); + it('should not resolve a value that merely starts with the deprecated prefix', () => { + // Given a workspace on an unknown value that only shares the deprecated prefix + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The value outside the type is the scenario under test. + const policy = createMock({id: '1', reimbursementChoice: 'deprecated_reimburseSomethingElse' as Policy['reimbursementChoice']}); - // When the choice is resolved - const resolvedChoice = getReimbursementChoice(policy); + // When the choice is resolved + const resolvedChoice = getReimbursementChoice(policy); - // Then it becomes the plain value it means, so every comparison downstream treats the workspace correctly - expect(resolvedChoice).toBe(expectedChoice); + // Then it is passed through, because matching on the prefix would silently invent a meaning for a value we do not know + expect(resolvedChoice).toBe('deprecated_reimburseSomethingElse'); + }); }); - it('should not resolve a value that merely starts with the deprecated prefix', () => { - // Given a workspace on an unknown value that only shares the deprecated prefix - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The value outside the type is the scenario under test. - const policy = createMock({id: '1', reimbursementChoice: 'deprecated_reimburseSomethingElse' as Policy['reimbursementChoice']}); + describe('arePaymentsEnabled', () => { + it.each([ + [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, true], + [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], + [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], + [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], + ])('should return %s -> %s', (choice, expected) => { + // Given a workspace on one of the current or deprecated values + const policy = createMock({id: '1', reimbursementChoice: choice}); - // When the choice is resolved - const resolvedChoice = getReimbursementChoice(policy); + // When payments availability is checked + const paymentsEnabled = arePaymentsEnabled(policy); - // Then it is passed through, because matching on the prefix would silently invent a meaning for a value we do not know - expect(resolvedChoice).toBe('deprecated_reimburseSomethingElse'); + // Then only a disabled workspace turns payments off, whichever of the two values it reports + expect(paymentsEnabled).toBe(expected); + }); }); - }); - describe('arePaymentsEnabled', () => { - it.each([ - [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, true], - [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], - [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], - [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, true], - [CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, false], - ])('should return %s -> %s', (choice, expected) => { - // Given a workspace on one of the current or deprecated values - const policy = createMock({id: '1', reimbursementChoice: choice}); + describe('isPolicyPayer', () => { + // The owner is the payer under Indirect reimbursement, so both values have to resolve to the same payer. + it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL])( + 'should treat the owner as the payer for %s', + (choice) => { + // Given an owner who administers a workspace on Indirect reimbursement + const policy = createMock({ + id: '1', + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: choice, + owner: 'owner@example.com', + }); + + // When they are checked against the workspace + const isPayer = isPolicyPayer(policy, 'owner@example.com'); + + // Then they can pay, so the deprecated value does not strip an owner of the Pay button + expect(isPayer).toBe(true); + }, + ); + + it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO])( + 'should treat nobody as the payer for %s', + (choice) => { + // Given an owner who administers a workspace with reimbursement disabled + const policy = createMock({ + id: '1', + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: choice, + owner: 'owner@example.com', + }); - // When payments availability is checked - const paymentsEnabled = arePaymentsEnabled(policy); + // When they are checked against the workspace + const isPayer = isPolicyPayer(policy, 'owner@example.com'); - // Then only a disabled workspace turns payments off, whichever of the two values it reports - expect(paymentsEnabled).toBe(expected); + // Then not even the owner can pay, so resolving the deprecated value does not hand out Pay where it was switched off + expect(isPayer).toBe(false); + }, + ); }); - }); - describe('isPolicyPayer', () => { - // The owner is the payer under Indirect reimbursement, so both values have to resolve to the same payer. - it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL])( - 'should treat the owner as the payer for %s', - (choice) => { - // Given an owner who administers a workspace on Indirect reimbursement + describe('getOwnerChangePayerSuccessData', () => { + it('should reassign policy.reimburser when the outgoing owner is the payer', () => { const policy = createMock({ id: '1', - role: CONST.POLICY.ROLE.ADMIN, - reimbursementChoice: choice, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + reimburser: 'owner@example.com', owner: 'owner@example.com', }); - // When they are checked against the workspace - const isPayer = isPolicyPayer(policy, 'owner@example.com'); - - // Then they can pay, so the deprecated value does not strip an owner of the Pay button - expect(isPayer).toBe(true); - }, - ); + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({reimburser: 'new@example.com'}); + }); - it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO])( - 'should treat nobody as the payer for %s', - (choice) => { - // Given an owner who administers a workspace with reimbursement disabled + it('should reassign achAccount.reimburser when that is where the payer is stored', () => { const policy = createMock({ id: '1', - role: CONST.POLICY.ROLE.ADMIN, - reimbursementChoice: choice, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + achAccount: {reimburser: 'owner@example.com'}, owner: 'owner@example.com', }); - // When they are checked against the workspace - const isPayer = isPolicyPayer(policy, 'owner@example.com'); - - // Then not even the owner can pay, so resolving the deprecated value does not hand out Pay where it was switched off - expect(isPayer).toBe(false); - }, - ); - }); - - describe('getOwnerChangePayerSuccessData', () => { - it('should reassign policy.reimburser when the outgoing owner is the payer', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - reimburser: 'owner@example.com', - owner: 'owner@example.com', + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({achAccount: {reimburser: 'new@example.com'}}); }); - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({reimburser: 'new@example.com'}); - }); + it('should not reassign when the workspace has a bank account, because the backend keeps the former payer', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + reimburser: 'owner@example.com', + achAccount: {bankAccountID: 1234, reimburser: 'owner@example.com'}, + owner: 'owner@example.com', + }); - it('should reassign achAccount.reimburser when that is where the payer is stored', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - achAccount: {reimburser: 'owner@example.com'}, - owner: 'owner@example.com', + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); }); - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({achAccount: {reimburser: 'new@example.com'}}); - }); + it('should not reassign when someone other than the outgoing owner is the payer', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + reimburser: 'payer@example.com', + owner: 'owner@example.com', + }); - it('should not reassign when the workspace has a bank account, because the backend keeps the former payer', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - reimburser: 'owner@example.com', - achAccount: {bankAccountID: 1234, reimburser: 'owner@example.com'}, - owner: 'owner@example.com', + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); }); - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); - }); + it('should not reassign when reimbursement is disabled', () => { + const policy = createMock({ + id: '1', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + reimburser: 'owner@example.com', + owner: 'owner@example.com', + }); - it('should not reassign when someone other than the outgoing owner is the payer', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - reimburser: 'payer@example.com', - owner: 'owner@example.com', + expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); }); - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); - }); - - it('should not reassign when reimbursement is disabled', () => { - const policy = createMock({ - id: '1', - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, - reimburser: 'owner@example.com', - owner: 'owner@example.com', + it('should return no payer fields for an undefined policy', () => { + expect(getOwnerChangePayerSuccessData(undefined, 'new@example.com')).toEqual({}); }); - - expect(getOwnerChangePayerSuccessData(policy, 'new@example.com')).toEqual({}); - }); - - it('should return no payer fields for an undefined policy', () => { - expect(getOwnerChangePayerSuccessData(undefined, 'new@example.com')).toEqual({}); }); }); From 04d0420a8e7984fd9c135ac8dae22ee274db52a0 Mon Sep 17 00:00:00 2001 From: "Situ Chandra Shil (via MelvinBot)" Date: Thu, 15 Oct 2026 08:31:31 +0000 Subject: [PATCH 735/781] Match the TimePicker landscape layout to the amount page Co-authored-by: Situ Chandra Shil --- src/components/TimePicker/TimePicker.tsx | 27 +++++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index 3e027311d873..1feb29272dd7 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -705,11 +705,14 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou } }; + // In landscape, the AM/PM buttons match the currency and Flip buttons on the amount page const renderedAmPmButtons = ( - + ); - // In landscape there isn't enough height to stack the inputs, number pad and Save button, so place the inputs next to - // the number pad inside a ScrollView and keep the Save button pinned below it. + // In landscape there isn't enough height to stack the inputs, number pad and Save button, so use the same layout as the + // amount page (NumberWithSymbolForm): inputs and AM/PM buttons next to the number pad, with the Save button pinned below. if (isInLandscapeMode) { return ( @@ -869,8 +874,14 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou > {renderedInputs} - {renderedErrorMessage} {renderedAmPmButtons} + {isError && ( + + )} {canUseTouchScreen && ( {}, shou )} - {renderedSaveButton} + {renderedSaveButton} ); } From 33820e654b2cd6321b67b3649dba2bd44daae023 Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 15 Oct 2026 11:47:09 +0200 Subject: [PATCH 736/781] chore: post-merge fixes --- src/pages/Insights/InsightsDashboard.tsx | 1 - src/pages/home/HomePage.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index 035f95269627..aa6072496dcc 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -130,7 +130,6 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index e15e44867918..40cfdf7197e3 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -162,7 +162,6 @@ function HomePage() { style={styles.homePageScrollView} {...tabRootScrollProps} onScroll={triggerScrollEvent} - contentContainerStyle={[styles.homePageContentContainer, pageGutter]} addBottomSafeAreaPadding keyboardShouldPersistTaps="handled" > From a7fe68701c88d99bcf4ca42afd36213ab771661f Mon Sep 17 00:00:00 2001 From: "nkdengineer (via MelvinBot)" Date: Thu, 15 Oct 2026 09:50:55 +0000 Subject: [PATCH 737/781] Explain the prefer-nullish-coalescing disable in getDefaultApprover Co-authored-by: nkdengineer --- src/libs/PolicyUtils/approvals.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/PolicyUtils/approvals.ts b/src/libs/PolicyUtils/approvals.ts index 860ef968e67f..28fcccc0cb17 100644 --- a/src/libs/PolicyUtils/approvals.ts +++ b/src/libs/PolicyUtils/approvals.ts @@ -85,6 +85,7 @@ function getApprovalWorkflow(policy: OnyxEntry): ValueOf): string { + // We intentionally use || here so an empty-string approver falls back to the owner. ?? would keep the empty string. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return policy?.approver || policy?.owner || ''; } From a3e01fdc818ffa02510fac5aabda563f3bc1df00 Mon Sep 17 00:00:00 2001 From: Hans Date: Thu, 15 Oct 2026 17:14:07 +0700 Subject: [PATCH 738/781] fix blockers related to join workspace --- src/libs/actions/Welcome/index.ts | 11 +- .../BaseOnboardingPrivateDomain.tsx | 6 +- .../BaseOnboardingWorkEmail.tsx | 5 +- tests/ui/WorkEmailOnboardingNavigation.tsx | 102 ++++++++++++++++++ 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index ef7a69b3499f..de53ddb61c37 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -108,8 +108,13 @@ function updateOnboardingValuesAndNavigation(onboardingValues: Onboarding | unde }); } -async function clearOnboardingShouldValidate(onboardingValues: Onboarding | undefined) { - await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {...onboardingValues, shouldValidate: undefined}); +async function resetOnboardingWorkEmailValidation(onboardingValues: Onboarding | undefined) { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, { + ...onboardingValues, + shouldValidate: undefined, + isMergeAccountStepCompleted: undefined, + isMergeAccountStepSkipped: undefined, + }); } function setOnboardingShouldValidate(shouldValidate: boolean) { @@ -312,7 +317,7 @@ export { createJoinWorkspaceOnboardingContent, setOnboardingShouldValidate, updateOnboardingValuesAndNavigation, - clearOnboardingShouldValidate, + resetOnboardingWorkEmailValidation, setOnboardingUserReportedIntegration, setOnboardingAccountingEnabled, setOnboardingInterestedFeaturesMap, diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index fda0d3c4b487..66bfc500c97a 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -108,13 +108,13 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard }, [email]); const handleBackButtonPress = useCallback(() => { - if (onboardingValues?.shouldValidate === false) { + const backTo = route.params?.backTo as Route | undefined; + if (onboardingValues?.shouldValidate === false && backTo !== ROUTES.ONBOARDING_PURPOSE.getRoute()) { updateOnboardingValuesAndNavigation(onboardingValues); return; } - const routeToNavigate = (route.params?.backTo as Route) ?? ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(); - Navigation.goBack(routeToNavigate); + Navigation.goBack(backTo ?? ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()); }, [route.params?.backTo, onboardingValues]); const navigateToNextOnboardingStep = useCallback( diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 74b567b1432c..0a87926ed754 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -32,8 +32,8 @@ import {AddWorkEmail} from '@userActions/Session'; import { addWorkEmailFormError, clearOnboardingMergeAccountBlocked, - clearOnboardingShouldValidate, clearWorkEmailFormErrors, + resetOnboardingWorkEmailValidation, setOnboardingErrorMessage, setOnboardingMergeAccountStepValue, setOnboardingMergingAccountBlocked, @@ -242,12 +242,13 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW // AddWorkEmail already made this the account's login, and resending it would merge the account into itself. if (sessionEmail && submittedWorkEmail.toLowerCase() === sessionEmail.toLowerCase()) { + setHasSubmittedWorkEmail(true); setOnboardingShouldValidate(false); return; } // The form's onSubmit must stay synchronous, so chain off the Onyx write instead of awaiting it. - clearOnboardingShouldValidate(onboardingValues).then(() => { + resetOnboardingWorkEmailValidation(onboardingValues).then(() => { setHasSubmittedWorkEmail(true); AddWorkEmail(submittedWorkEmail, addWorkEmailTaskReport); }); diff --git a/tests/ui/WorkEmailOnboardingNavigation.tsx b/tests/ui/WorkEmailOnboardingNavigation.tsx index f246d87a36ad..355bc61c5810 100644 --- a/tests/ui/WorkEmailOnboardingNavigation.tsx +++ b/tests/ui/WorkEmailOnboardingNavigation.tsx @@ -11,10 +11,12 @@ import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; import {AddWorkEmail, MergeIntoAccountAndLogin} from '@libs/actions/Session'; import HttpUtils from '@libs/HttpUtils'; +import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; +import OnboardingPrivateDomain from '@pages/OnboardingPrivateDomain'; import OnboardingWorkEmail from '@pages/OnboardingWorkEmail'; import OnboardingWorkEmailValidation from '@pages/OnboardingWorkEmailValidation'; import OnboardingWorkspaces from '@pages/OnboardingWorkspaces'; @@ -23,6 +25,7 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Onboarding, Response as OnyxResponse} from '@src/types/onyx'; @@ -121,6 +124,18 @@ function OnboardingModalNavigatorWithPurpose() { name={SCREENS.ONBOARDING.WORK_EMAIL} component={OnboardingWorkEmail} /> + + + ); } @@ -421,6 +436,93 @@ describe('Onboarding work email navigation', () => { expect(onboardingValues?.isMergingAccountBlocked).toBe(false); }); + it('should reopen magic code after skipping an existing work email and submitting it again', async () => { + // Given a public-domain user joining a workspace with an existing work account + await TestHelper.signInWithTestUser(1, 'test@gmail.com'); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + }); + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL], OnboardingModalNavigatorWithPurpose); + await waitForBatchedUpdatesWithAct(); + + const restoreXhr = mockResponseOnce({shouldValidate: true}); + try { + fireEvent.changeText(screen.getByLabelText(TestHelper.translateLocal('common.workEmail')), workEmail); + fireEvent.press(screen.getByText(TestHelper.translateLocal('onboarding.workEmail.addWorkEmail'))); + await waitForBatchedUpdatesWithAct(); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL_VALIDATION]); + + // When they skip validation, return to the intent list, and submit the same email on a new visit + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.skip'))); + await waitForBatchedUpdatesWithAct(); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.PERSONAL_DETAILS]); + act(() => Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute())); + await waitForBatchedUpdatesWithAct(); + act(() => Navigation.navigate(ROUTES.ONBOARDING_WORK_EMAIL.getRoute())); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText(TestHelper.translateLocal('onboarding.workEmail.addWorkEmail'))); + await waitForBatchedUpdatesWithAct(); + + // Then stale completion flags must not immediately skip the newly opened validation screen + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL_VALIDATION]); + expect(screen.queryByTestId('onboarding-personal-details-stub')).not.toBeOnTheScreen(); + } finally { + restoreXhr(); + } + }); + + it('should resume primary work email validation after going back and resubmitting', async () => { + // Given a work email that became the unvalidated primary login on the first AddWorkEmail request + await TestHelper.signInWithTestUser(1, workEmail); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: false}); + await Onyx.set(ONYXKEYS.LOGINS, {[workEmail]: {validatedDate: ''}}); + }); + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL], OnboardingModalNavigatorWithPurpose); + await waitForBatchedUpdatesWithAct(); + + // When the same primary email is submitted, including with different casing + fireEvent.changeText(screen.getByLabelText(TestHelper.translateLocal('common.workEmail')), workEmail.toUpperCase()); + fireEvent.press(screen.getByText(TestHelper.translateLocal('onboarding.workEmail.addWorkEmail'))); + await waitForBatchedUpdatesWithAct(); + + // Then it opens validation, and Back still restores the work-email form for another submission + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.PRIVATE_DOMAIN]); + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('common.back'))); + await waitForBatchedUpdatesWithAct(); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK_EMAIL]); + fireEvent.press(screen.getByText(TestHelper.translateLocal('onboarding.workEmail.addWorkEmail'))); + await waitForBatchedUpdatesWithAct(); + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.PRIVATE_DOMAIN]); + }); + + it('should return directly to purpose when private-domain validation was opened from the intent list', async () => { + // Given an unvalidated private-domain user whose earlier direct add left shouldValidate false + await TestHelper.signInWithTestUser(1, workEmail); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false, shouldValidate: false}); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE); + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: false}); + await Onyx.set(ONYXKEYS.LOGINS, {[workEmail]: {validatedDate: ''}}); + }); + renderOnboardingStack([SCREENS.ONBOARDING.PURPOSE], OnboardingModalNavigatorWithPurpose); + await waitForBatchedUpdatesWithAct(); + act(() => Navigation.navigate(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(ROUTES.ONBOARDING_PURPOSE.getRoute()))); + await waitForBatchedUpdatesWithAct(); + + // When Back is pressed on the screen reached directly from Join my company workspace + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('common.back'))); + await waitForBatchedUpdatesWithAct(); + + // Then it returns to the actual origin without inserting a work-email screen + expect(getOnboardingRouteNames()).toEqual([SCREENS.ONBOARDING.PURPOSE]); + expect(screen.getByTestId('onboarding-purpose-stub')).toBeOnTheScreen(); + }); + it('should keep Back working on a later visit to Join a workspace after the merge', async () => { await TestHelper.signInWithTestUser(); From f059363d3dc9108e1904739fd299852f4eed502d Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 15 Oct 2026 13:12:53 +0200 Subject: [PATCH 739/781] fix: broken insets on offline state --- .../LHNOptionsList/LHNOptionsList.tsx | 2 +- .../useTabRootScrollProps/index.android.tsx | 13 +++++++ .../useTabRootScrollProps/index.ios.tsx | 35 +++++++++++++++++++ .../useTabRootScrollProps/index.native.tsx | 13 ------- .../useTabRootScrollProps/index.tsx | 11 +++--- .../useTabRootScrollProps/types.ts | 12 +++++++ src/components/Search/index.tsx | 4 ++- src/pages/Insights/InsightsDashboard.tsx | 6 +++- src/pages/Search/EmptySearchView.tsx | 4 ++- src/pages/home/HomePage.tsx | 2 +- src/pages/inbox/sidebar/SidebarLinks.tsx | 4 ++- tests/ui/WorkspaceInitialPageTest.tsx | 1 + 12 files changed, 83 insertions(+), 24 deletions(-) create mode 100644 src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.android.tsx create mode 100644 src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.ios.tsx delete mode 100644 src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 5b48576d3f3e..7ca6a3d66b80 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -46,7 +46,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const [personalDetails] = useAllPersonalDetails(); const styles = useThemeStyles(); - const tabRootScrollProps = useTabRootScrollProps(contentContainerStyles, true); + const tabRootScrollProps = useTabRootScrollProps(contentContainerStyles, {hasBottomSafeAreaPadding: true}); const estimatedItemSize = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; // When the first item renders we want to call the onFirstItemRendered callback. diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.android.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.android.tsx new file mode 100644 index 000000000000..3a8b5817efe6 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.android.tsx @@ -0,0 +1,13 @@ +import type {StyleProp, ViewStyle} from 'react-native'; + +import type TabRootScrollProps from './types'; + +/** + * A tab root screen reserves the row of Material's bar below its content, with the offline indicator laid out above + * it, so neither covers the list and the content container keeps its style. + */ +function useTabRootScrollProps(style?: StyleProp): TabRootScrollProps { + return {contentContainerStyle: style}; +} + +export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.ios.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.ios.tsx new file mode 100644 index 000000000000..5e49f3363807 --- /dev/null +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.ios.tsx @@ -0,0 +1,35 @@ +import useTabBarCoveredHeight from '@components/Navigation/TabBarBottomContent/useTabBarCoveredHeight'; + +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import type TabRootScrollProps from './types'; +import type {TabRootScrollOptions} from './types'; + +/** + * UIKit insets the end of a list past the translucent UITabBar that the content runs under, but not past the offline + * indicator laid over the content above the bar, so the list ends with room for it. UIKit's inset leaves the layout of + * the content alone, so content that fills the list and is laid out in it, like a centered empty state, would sit + * partly under the bar; it pads past the bar itself instead. + */ +function useTabRootScrollProps( + style?: StyleProp, + {hasBottomSafeAreaPadding = false, hasOfflineIndicatorPadding = false, fillsVisibleArea = false}: TabRootScrollOptions = {}, +): TabRootScrollProps { + const tabBarCoveredHeight = useTabBarCoveredHeight(); + const {paddingBottom: safeAreaPaddingBottom} = useSafeAreaPaddings(true); + const isCoveredByTabBar = tabBarCoveredHeight > 0; + const coveredHeightPadding = hasBottomSafeAreaPadding ? tabBarCoveredHeight - safeAreaPaddingBottom : tabBarCoveredHeight; + + const contentContainerStyle = useBottomSafeSafeAreaPaddingStyle({ + style, + addOfflineIndicatorBottomSafeAreaPadding: isCoveredByTabBar && !hasOfflineIndicatorPadding, + additionalPaddingBottom: isCoveredByTabBar && fillsVisibleArea ? coveredHeightPadding : 0, + }); + + return {contentContainerStyle, contentInsetAdjustmentBehavior: fillsVisibleArea ? 'never' : 'automatic'}; +} + +export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx deleted file mode 100644 index 65aaa5912a7a..000000000000 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.native.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type {StyleProp, ViewStyle} from 'react-native'; - -import type TabRootScrollProps from './types'; - -/** - * UIKit insets the end of the list past the translucent iOS tab bar that the content runs under, and Android reserves - * the bar's row below the content, so the content container keeps its style. - */ -function useTabRootScrollProps(style?: StyleProp): TabRootScrollProps { - return {contentContainerStyle: style, contentInsetAdjustmentBehavior: 'automatic'}; -} - -export default useTabRootScrollProps; diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx index c75a5c774fc8..48c6212dd36d 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/index.tsx @@ -6,18 +6,19 @@ import variables from '@styles/variables'; import type {StyleProp, ViewStyle} from 'react-native'; import type TabRootScrollProps from './types'; +import type {TabRootScrollOptions} from './types'; /** - * The content of a narrow tab root screen runs under the floating tab bar, so a list's content container ends with - * room for the bar, letting its last row scroll out from under it. A list that already adds the bottom safe area - * passes `hasBottomSafeAreaPadding`, so the safe area is not added twice. + * The content of a narrow tab root screen runs under the floating tab bar and the offline indicator laid over it, so a + * list's content container ends with room for both, letting its last row scroll out from under them. A list that + * already adds the bottom safe area or the offline indicator's room says so, so neither is added twice. */ -function useTabRootScrollProps(style?: StyleProp, hasBottomSafeAreaPadding = false): TabRootScrollProps { +function useTabRootScrollProps(style?: StyleProp, {hasBottomSafeAreaPadding = false, hasOfflineIndicatorPadding = false}: TabRootScrollOptions = {}): TabRootScrollProps { const {shouldUseNarrowLayout} = useResponsiveLayout(); const insetStyle = useBottomSafeSafeAreaPaddingStyle({ style, addBottomSafeAreaPadding: !hasBottomSafeAreaPadding, - addOfflineIndicatorBottomSafeAreaPadding: false, + addOfflineIndicatorBottomSafeAreaPadding: !hasOfflineIndicatorPadding, additionalPaddingBottom: variables.floatingTabBarHeight + variables.floatingTabBarBottomInset, }); diff --git a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts index 61dc387fad90..8dbd16a0f580 100644 --- a/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts +++ b/src/components/Navigation/TabBarBottomContent/useTabRootScrollProps/types.ts @@ -2,4 +2,16 @@ import type {ScrollViewProps} from 'react-native'; type TabRootScrollProps = Pick; +type TabRootScrollOptions = { + /** The list already ends with the bottom safe area */ + hasBottomSafeAreaPadding?: boolean; + + /** The list already ends with room for the offline indicator, as a ScrollView with `addBottomSafeAreaPadding` does */ + hasOfflineIndicatorPadding?: boolean; + + /** The content container grows to the height of the list and lays its content out in it, as a centered empty state does */ + fillsVisibleArea?: boolean; +}; + export default TabRootScrollProps; +export type {TabRootScrollOptions}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b439cae7cbf0..2d60aeb3551d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,5 +1,6 @@ import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; @@ -159,6 +160,7 @@ function Search({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout, isLargeScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const styles = useThemeStyles(); + const chartTabRootScrollProps = useTabRootScrollProps(styles.flexGrow1, {fillsVisibleArea: true}); const navigation = useNavigation>(); const isFocused = useIsFocused(); @@ -1424,7 +1426,7 @@ function Search({ diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 40cfdf7197e3..edfa2a4176ca 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -48,7 +48,7 @@ function HomePage() { const styles = useThemeStyles(); const {pageGutter} = useLayoutSpacing(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.HOME); - const tabRootScrollProps = useTabRootScrollProps([styles.homePageContentContainer, pageGutter], true); + const tabRootScrollProps = useTabRootScrollProps([styles.homePageContentContainer, pageGutter], {hasBottomSafeAreaPadding: true, hasOfflineIndicatorPadding: true}); const {translate} = useLocalize(); const triggerScrollEvent = useScrollEventEmitter(); useDocumentTitle(translate('common.home')); diff --git a/src/pages/inbox/sidebar/SidebarLinks.tsx b/src/pages/inbox/sidebar/SidebarLinks.tsx index 6af8512410a8..00189aa18cfc 100644 --- a/src/pages/inbox/sidebar/SidebarLinks.tsx +++ b/src/pages/inbox/sidebar/SidebarLinks.tsx @@ -1,5 +1,6 @@ import LHNEmptyState from '@components/LHNOptionsList/LHNEmptyState'; import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList'; +import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/useTabRootScrollProps'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import useOnyx from '@hooks/useOnyx'; @@ -82,6 +83,7 @@ function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CO const contentContainerStyles = StyleSheet.flatten([styles.pt2, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}]); const shouldShowEmptyLHN = optionListItems.length === 0; + const {contentContainerStyle: emptyLHNStyle} = useTabRootScrollProps([styles.flex1, isInLandscapeMode ? styles.pv4 : styles.emptyLHNWrapper], {fillsVisibleArea: true}); // Show the loading skeleton only while report data is loading AND the entire LHN report set is empty // (e.g. switching accounts/delegate sessions or re-authenticating after an Onyx clear, where openApp() repopulates reports). @@ -93,7 +95,7 @@ function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CO {shouldShowEmptyLHN ? ( - + ) : ( diff --git a/tests/ui/WorkspaceInitialPageTest.tsx b/tests/ui/WorkspaceInitialPageTest.tsx index be251e800ca6..693b4ecb8146 100644 --- a/tests/ui/WorkspaceInitialPageTest.tsx +++ b/tests/ui/WorkspaceInitialPageTest.tsx @@ -68,6 +68,7 @@ jest.mock('@components/BlockingViews/FullPageNotFoundView', () => (props: {child jest.mock('@components/HeaderWithBackButton', () => jest.fn()); jest.mock('@components/HighlightableMenuItem', () => jest.fn()); jest.mock('@components/Navigation/TabBarBottomContent/useTabRootScreenWrapperProps', () => () => ({})); +jest.mock('@components/Navigation/TabBarBottomContent/useTabRootScrollProps', () => () => ({})); jest.mock( '@components/OfflineWithFeedback', () => From 6def640b9192cee23e07bc8e4584253934157bab Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 15 Oct 2026 13:21:55 +0200 Subject: [PATCH 740/781] fix: move offline state to the top --- src/pages/Search/SearchPageNarrow/index.tsx | 5 ++++- src/styles/index.ts | 5 +++++ src/styles/variables.ts | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 28e04ea561cf..2fde215162e1 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -410,7 +410,10 @@ function SearchPageNarrow({ )} - setFooterHeight(event.nativeEvent.layout.height)}> + setFooterHeight(event.nativeEvent.layout.height)} + > right: 0, bottom: footerHeight, left: 0, + zIndex: variables.searchFooterZIndex, backgroundColor: theme.appBG, }) satisfies ViewStyle, @@ -7724,6 +7725,10 @@ const plainStyles = (theme: ThemeColors) => zIndex: variables.searchTopBarZIndex, }, + searchFooterZIndexStyle: { + zIndex: variables.searchFooterZIndex, + }, + getWidgetContainerTitleStyle: (color: string) => ({ ...textVariants.textStrong, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 90264b1b31c5..46f3b556dfea 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -484,6 +484,8 @@ export default { searchListContentMarginTop: 120, searchListContentWithFiltersMarginTop: 164, searchTopBarZIndex: 9, + // Above the search page header (zIndex 10), whose top bar floats over the list and reaches the footer when the screen is short. + searchFooterZIndex: 11, searchAutocompleteInputSkeletonHeight: 8, searchAutocompleteInputSkeletonWidth: 145, From 4da67c72adda5933655534a9071efc95cab390ad Mon Sep 17 00:00:00 2001 From: Hans Date: Thu, 15 Oct 2026 18:23:49 +0700 Subject: [PATCH 741/781] add comment --- .../OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index 66bfc500c97a..afb793632f04 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -109,6 +109,8 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard const handleBackButtonPress = useCallback(() => { const backTo = route.params?.backTo as Route | undefined; + // When this screen was opened directly from the intent list (backTo is the purpose route), go straight + // back there instead of running the work-email restore path, which would insert a work-email screen. if (onboardingValues?.shouldValidate === false && backTo !== ROUTES.ONBOARDING_PURPOSE.getRoute()) { updateOnboardingValuesAndNavigation(onboardingValues); return; From 26cfdc5447917dbc5257d9d916cf5ca41c083ca6 Mon Sep 17 00:00:00 2001 From: Hans Date: Thu, 15 Oct 2026 18:50:35 +0700 Subject: [PATCH 742/781] fix review --- src/libs/actions/Welcome/OnboardingFlow.ts | 5 ++- tests/unit/OnboardingFlowTest.ts | 47 ++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 42bcb7d03af8..29275a7037d2 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -144,8 +144,11 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI CONST.ONBOARDING_CHOICES.TRACK_PERSONAL, ]); } + // A successful direct add can leave the account's public-domain flag stale until the next account update. + const shouldResumePrivateDomainValidation = currentOnboardingValues?.shouldValidate === false && initialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route); + // A validated account has no reason to be on the onboarding "add work email" screen. - if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated) { + if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated && !shouldResumePrivateDomainValidation) { return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } diff --git a/tests/unit/OnboardingFlowTest.ts b/tests/unit/OnboardingFlowTest.ts index 68988e7f0425..9509b981f763 100644 --- a/tests/unit/OnboardingFlowTest.ts +++ b/tests/unit/OnboardingFlowTest.ts @@ -6,6 +6,7 @@ import navigationRef from '@libs/Navigation/navigationRef'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as NativeNavigation from '@react-navigation/native'; @@ -21,6 +22,8 @@ describe('OnboardingFlow', () => { beforeAll(() => IntlStore.load(CONST.LOCALES.EN)); describe('getOnboardingInitialPath', () => { + const savedPrivateDomainPath = `/${ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(ROUTES.ONBOARDING_WORK_EMAIL.getRoute())}`; + it('should return the onboarding fallback path when the last visited path is null', () => { const params: GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: false, @@ -190,8 +193,7 @@ describe('OnboardingFlow', () => { }); it('should not redirect away from a private-domain URL for a public-domain unvalidated user', () => { - // Mirrors the BaseOnboardingPrivateDomain screen-level guard: an unvalidated public-domain user who just - // submitted a work email may land here while isFromPublicDomain is stale. They must keep the private-domain step. + // Given an unvalidated user submitted a work email while isFromPublicDomain is still stale const params: GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: true, hasAccessiblePolicies: true, @@ -209,10 +211,49 @@ describe('OnboardingFlow', () => { onboardingValues: undefined, isAccountValidated: false, }; + + // When onboarding resumes after a refresh const path = getOnboardingInitialPath(params); - expect(path).not.toBe('/onboarding/purpose'); + + // Then it preserves the private-domain validation step + expect(path).toBe('/onboarding/private-domain'); }); + it.each([ + {shouldValidate: false, isAccountValidated: false, expectedPath: savedPrivateDomainPath}, + {shouldValidate: undefined, isAccountValidated: false, expectedPath: '/onboarding/work-email'}, + {shouldValidate: true, isAccountValidated: false, expectedPath: '/onboarding/work-email'}, + {shouldValidate: false, isAccountValidated: true, expectedPath: '/onboarding/purpose'}, + ])( + 'resumes at $expectedPath after resetting merge flags with shouldValidate=$shouldValidate and validated=$isAccountValidated', + ({shouldValidate, isAccountValidated, expectedPath}) => { + // Given a previously skipped work email was resubmitted, clearing its merge flags, while the domain flag is stale + const onboardingValues = { + hasCompletedGuidedSetupFlow: false, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + isMergeAccountStepCompleted: undefined, + isMergeAccountStepSkipped: undefined, + shouldValidate, + }; + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + isAccountValidated, + hasAccessiblePolicies: false, + onboardingValuesParam: onboardingValues, + onboardingValues, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + currentOnboardingCompanySize: undefined, + onboardingInitialPath: savedPrivateDomainPath, + }; + + // When refreshing restores the saved onboarding route + const path = getOnboardingInitialPath(params); + + // Then only a successful direct add for an unvalidated account resumes the exact validation route + expect(path).toBe(expectedPath); + }, + ); + it('should not redirect away from a work-email-validation URL for a public-domain user', () => { const params: GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: true, From cffdf5d2739e2babb3025858348fd0933569ee80 Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 15 Oct 2026 14:13:04 +0200 Subject: [PATCH 743/781] fix: make tabs take as much space as they need --- .../NavigationTabBar/InboxTabButton.tsx | 2 +- .../NavigationTabBar/InsightsTabButton.tsx | 2 +- .../NavigationTabBar/SearchTabButton.tsx | 2 +- .../NavigationTabBar/WorkspacesTabButton.tsx | 2 +- .../Navigation/NavigationTabBar/index.tsx | 2 +- .../inbox/sidebar/NavigationTabBarAvatar.tsx | 2 +- src/styles/index.ts | 20 +++++++++++++++---- src/styles/variables.ts | 4 ++++ 8 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx index 3902325f5668..9f7fb4fe2015 100644 --- a/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InboxTabButton.tsx @@ -233,7 +233,7 @@ function InboxTabButton({selectedTab, isWideLayout}: InboxTabButtonProps) { role={CONST.ROLE.TAB} accessibilityLabel={accessibilityLabel} accessibilityState={{selected: selectedTab === NAVIGATION_TABS.INBOX}} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INBOX} > diff --git a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx index 19d2e6ecb5dd..0042091fab77 100644 --- a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx @@ -67,7 +67,7 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps) role={CONST.ROLE.TAB} accessibilityLabel={translate('common.insights')} accessibilityState={{selected: isSelected}} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.INSIGHTS} > diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 6cb649aca30f..274dabf14b6e 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -77,7 +77,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { role={CONST.ROLE.TAB} accessibilityLabel={translate('common.spend')} accessibilityState={searchAccessibilityState} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.REPORTS} > diff --git a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx index f996c54f0522..507ec3a3239a 100644 --- a/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/WorkspacesTabButton.tsx @@ -61,7 +61,7 @@ function WorkspacesTabButton({selectedTab, isWideLayout}: WorkspacesTabButtonPro role={CONST.ROLE.TAB} accessibilityLabel={`${translate('common.workspacesTabTitle')}${workspacesTabIndicatorStatus ? `. ${translate('common.yourReviewIsRequired')}` : ''}`} accessibilityState={workspacesAccessibilityState} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.WORKSPACES} > diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index 432f205cd96f..0f988d857280 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -161,7 +161,7 @@ function NavigationTabBar({selectedTab, shouldShowFloatingButtons = true}: Navig onPress={navigateToNewDotHome} role={CONST.ROLE.TAB} accessibilityLabel={translate('common.home')} - wrapperStyle={styles.flex1} + wrapperStyle={styles.navigationTabBarSlot} style={styles.navigationTabBarItem} sentryLabel={CONST.SENTRY_LABEL.NAVIGATION_TAB_BAR.HOME} > diff --git a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx index 6699d926b7f5..f308c8706689 100644 --- a/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx +++ b/src/pages/inbox/sidebar/NavigationTabBarAvatar.tsx @@ -37,7 +37,7 @@ function NavigationTabBarAvatar({onPress, isSelected = false, style}: Navigation onPress={onPress} accessibilityLabel={`${translate('initialSettingsPage.account')}, ${translate('sidebarScreen.buttonMySettings')}. ${status ? `${translate('common.yourReviewIsRequired')}.` : ''}`} role={CONST.ROLE.TAB} - wrapperStyle={styles.flex1} + wrapperStyle={shouldUseNarrowLayout ? styles.navigationTabBarSlot : styles.flex1} accessibilityState={accountAccessibilityState} aria-selected={accountAccessibilityState.selected} style={({hovered}) => [style, !shouldUseNarrowLayout && hovered && styles.navigationTabBarItemHovered]} diff --git a/src/styles/index.ts b/src/styles/index.ts index 79d58695bd3a..5da7bddb6d58 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -767,7 +767,13 @@ const staticStyles = (theme: ThemeColors) => // Only the mobile web bar uses this; iOS and Android draw native tab bars. navigationTabBarContainer: { - flexDirection: 'row', + // Grows every tab by the same amount until its label fits, then shares what is left between them, so a long + // label gets the room it needs and, once the labels no longer all fit, the long ones truncate to the same + // width while the short ones keep theirs. + display: 'grid', + gridTemplateColumns: `repeat(${variables.floatingTabBarTabCount}, minmax(0, auto))`, + // react-native-web defaults alignContent to flex-start, which would leave the row at its content's height. + alignContent: 'stretch', height: variables.floatingTabBarHeight, marginHorizontal: variables.floatingTabBarHorizontalInset, borderRadius: variables.componentBorderRadiusCircle, @@ -782,6 +788,11 @@ const staticStyles = (theme: ThemeColors) => overflow: 'hidden', }, + // A grid item is otherwise never narrower than its label, so the label could not truncate. + navigationTabBarSlot: { + minWidth: 0, + }, + // Stretches every tab to its slot, so a long label truncates inside the slot instead of spilling into its neighbors. navigationTabBarItemNarrow: { alignSelf: 'stretch', @@ -813,10 +824,11 @@ const staticStyles = (theme: ThemeColors) => alignItems: 'center', }, - // Lets the floating bar's avatar overflow the glyph box evenly above and below, so its label lines up - // with the labels of the icons next to it and its centre with theirs. + // Sits the avatar's bottom edge where the glyphs next to it end, inside their box, and lets its extra height + // reach above the box, so its label lines up with theirs and is the same distance from the avatar. navigationTabBarAvatar: { - marginVertical: (variables.iconFloatingTabBar - variables.avatarFloatingTabBar) / 2, + marginTop: variables.iconFloatingTabBar - variables.avatarFloatingTabBar - variables.floatingTabBarGlyphBottomInset, + marginBottom: variables.floatingTabBarGlyphBottomInset, }, navigationTabBarFABItem: { diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 46f3b556dfea..b4220e670890 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -38,6 +38,8 @@ export default { // Space between a label's truncation edge and its tab's edge, so a neighbor's selected pill never reaches the label. floatingTabBarLabelInset: 8, floatingTabBarBottomInset: 8, + // Home, Inbox, Spend and Workspaces, with Insights or Account as the fifth. + floatingTabBarTabCount: 5, // styles.p3 (12) on each side of the DebugTabView row plus the View button (componentSizeNormal). debugTabViewHeight: 64, contentHeaderHeight: getValueUsingPixelRatio(72, 100), @@ -110,6 +112,8 @@ export default { iconFloatingTabBar: 20, /** Account avatar size in the floating tab bar. */ avatarFloatingTabBar: avatarSizes.avatarSizeXSmall, + // The tab glyphs are drawn about this far above the bottom of their box. + floatingTabBarGlyphBottomInset: 1, /** Glyph size in the iOS native tab bar. */ iconNativeTabBarIOS: 22, /** Account avatar size in the iOS native tab bar. */ From ad21395d8629627e90f97415fcd413222b4d3456 Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 15 Oct 2026 14:24:16 +0200 Subject: [PATCH 744/781] fix: make the tab layout inline in portrait --- .../NativeTabNavigator/tabIconRasterizer.ts | 67 +++++++++++++++---- .../useNativeTabBarOptions/index.android.ts | 1 + .../useNativeTabBarOptions/index.ios.ts | 17 ++++- src/styles/variables.ts | 6 +- 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts index baeb69da5aaa..0eb625077854 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer.ts @@ -17,6 +17,8 @@ type TabIconLayout = { glyphSize: number; /** Side of the account avatar's box. */ avatarSize: number; + /** Whether the label is drawn under the glyph or beside it, as UIKit lays out a tab in landscape. */ + direction: 'stacked' | 'inline'; }; type TabIconLabel = { @@ -133,18 +135,59 @@ function drawStatusDot(canvas: SkCanvas, right: number, top: number, scale: numb paint.dispose(); } +type TabIconFrame = { + canvasWidth: number; + canvasHeight: number; + contentLeft: number; + contentTop: number; + labelLeft: number; + labelTop: number; +}; + +/** + * With a label, every tab gives its glyph or avatar a box as big as the avatar, so glyphs and the avatar share a centre + * and every label starts at the same place: under that box, or beside it in landscape. + */ +function getTabIconFrame(layout: TabIconLayout, contentSize: number, labelGap: number, labelWidth: number, labelHeight: number, hasLabel: boolean, scale: number): TabIconFrame { + const boxSize = hasLabel ? Math.max(layout.glyphSize, layout.avatarSize) * scale : contentSize; + + if (layout.direction === 'inline') { + const canvasHeight = Math.ceil(Math.max(boxSize, labelHeight)); + return { + canvasWidth: Math.ceil(boxSize + (hasLabel ? labelGap + labelWidth : 0)), + canvasHeight, + contentLeft: (boxSize - contentSize) / 2, + contentTop: (canvasHeight - contentSize) / 2, + labelLeft: boxSize + labelGap, + labelTop: (canvasHeight - labelHeight) / 2, + }; + } + + const canvasWidth = Math.ceil(Math.max(contentSize, labelWidth)); + const labelTop = (boxSize + layout.glyphSize * scale) / 2 + labelGap; + return { + canvasWidth, + canvasHeight: Math.ceil(hasLabel ? labelTop + labelHeight : boxSize), + contentLeft: (canvasWidth - contentSize) / 2, + contentTop: (boxSize - contentSize) / 2, + labelLeft: (canvasWidth - labelWidth) / 2, + labelTop, + }; +} + function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, label}: TabIconParams): NativeBottomTabIcon | undefined { const scale = PixelRatio.get(); const contentSize = (avatar ? layout.avatarSize : layout.glyphSize) * scale; - // With a label, every tab shares one row as tall as the avatar, so glyphs and the avatar share a centre and - // every label lands at the same height. - const rowSize = label ? Math.max(layout.glyphSize, layout.avatarSize) * scale : contentSize; - const labelTop = (rowSize + layout.glyphSize * scale) / 2 + (label?.gap ?? 0) * scale; const paragraph = label ? makeLabelParagraph(label, scale) : undefined; - const labelWidth = paragraph?.getLongestLine() ?? 0; - const labelHeight = paragraph?.getHeight() ?? 0; - const canvasWidth = Math.ceil(Math.max(contentSize, labelWidth)); - const canvasHeight = Math.ceil(label ? labelTop + labelHeight : rowSize); + const {canvasWidth, canvasHeight, contentLeft, contentTop, labelLeft, labelTop} = getTabIconFrame( + layout, + contentSize, + (label?.gap ?? 0) * scale, + paragraph?.getLongestLine() ?? 0, + paragraph?.getHeight() ?? 0, + !!label, + scale, + ); const surface = Skia.Surface.Make(canvasWidth, canvasHeight); if (!surface) { @@ -153,8 +196,6 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe } const canvas = surface.getCanvas(); - const contentLeft = (canvasWidth - contentSize) / 2; - const contentTop = (rowSize - contentSize) / 2; if (avatar) { drawCircularImage(canvas, avatar.image, contentLeft, contentTop, contentSize); } else { @@ -164,7 +205,7 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe drawStatusDot(canvas, contentLeft + contentSize, contentTop, scale, dotColor); } if (paragraph) { - paragraph.paint(canvas, (canvasWidth - labelWidth) / 2, labelTop); + paragraph.paint(canvas, labelLeft, labelTop); paragraph.dispose(); } @@ -187,8 +228,8 @@ function drawTabIcon(layout: TabIconLayout, {name, color, avatar, dotColor, labe */ function getTabIcon(layout: TabIconLayout, params: TabIconParams): NativeBottomTabIcon | undefined { const {name, color, avatar, dotColor, label} = params; - // The layout and the label's font are fixed per platform, so they are left out of the key. - const key = [name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, label?.maxWidth].join('|'); + // The label's font is fixed per platform, so it is left out of the key. + const key = [layout.direction, name, avatar?.uri ?? color, dotColor, label?.text, label?.color, label?.isBold, label?.maxWidth].join('|'); if (iconCache.has(key)) { return iconCache.get(key); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts index 296c291b2a63..d439553f17ae 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.android.ts @@ -24,6 +24,7 @@ const getFloatingButtonsBottom = (bottomInset: number) => variables.androidNativ const TAB_ICON_LAYOUT: TabIconLayout = { glyphSize: variables.iconBottomBar, avatarSize: variables.iconBottomBar, + direction: 'stacked', }; function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, dotColors, tabLabels}: NativeTabBarOptionsParams) { diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts index fc210e7c1ed5..81627df46fd6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useNativeTabBarOptions/index.ios.ts @@ -1,3 +1,4 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -22,6 +23,14 @@ const TAB_ICON_LAYOUT: TabIconLayout = { glyphSize: variables.iconNativeTabBarIOS, // A circle reads smaller than a glyph of the same box, so the avatar is drawn a little larger than the glyphs. avatarSize: variables.avatarNativeTabBarIOS, + direction: 'stacked', +}; + +/** A phone in landscape gives the bar too little height for a stacked tab, so UIKit lays its own tabs out this way. */ +const LANDSCAPE_TAB_ICON_LAYOUT: TabIconLayout = { + glyphSize: variables.iconNativeTabBarIOSLandscape, + avatarSize: variables.avatarNativeTabBarIOSLandscape, + direction: 'inline', }; /** @@ -33,9 +42,13 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, d const theme = useTheme(); const avatar = useTabAvatarImage(isAccountAvatarShown); const {windowWidth} = useWindowDimensions(); + const {isInLandscapeMode} = useResponsiveLayout(); + const tabIconLayout = isInLandscapeMode ? LANDSCAPE_TAB_ICON_LAYOUT : TAB_ICON_LAYOUT; // One of the tabs never has a bar item, so the bar splits its width between the others. const barItemCount = Object.keys(tabLabels).length - 1; - const labelMaxWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount - variables.iosNativeTabBarLabelInset; + const itemWidth = (windowWidth - 2 * variables.iosNativeTabBarHorizontalInset) / barItemCount - variables.iosNativeTabBarLabelInset; + // In landscape the label shares the item's width with the glyph's box beside it. + const labelMaxWidth = isInLandscapeMode ? itemWidth - tabIconLayout.avatarSize - variables.nativeTabIconLabelGap : itemWidth; const screenOptions: NativeBottomTabNavigationOptions = { // The labels are drawn into the icons. @@ -51,7 +64,7 @@ function useNativeTabBarOptions({shouldShowNativeTabBar, isAccountAvatarShown, d }; const getTabOptions = (name: NativeTabName): NativeBottomTabNavigationOptions => ({ - tabBarIcon: getTabBarIcon(TAB_ICON_LAYOUT, (isSelected) => ({ + tabBarIcon: getTabBarIcon(tabIconLayout, (isSelected) => ({ name, color: isSelected ? theme.iconMenu : theme.icon, avatar: name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR ? avatar : undefined, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index b4220e670890..d5f2c5c514a1 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -118,11 +118,15 @@ export default { iconNativeTabBarIOS: 22, /** Account avatar size in the iOS native tab bar. */ avatarNativeTabBarIOS: 26, + /** Glyph size in the iOS native tab bar in landscape, where UIKit lays the label beside a smaller icon. */ + iconNativeTabBarIOSLandscape: 18, + /** Account avatar size in the iOS native tab bar in landscape. */ + avatarNativeTabBarIOSLandscape: 21, /** Radius of the status dot drawn into a native tab bar icon. */ nativeTabIconDotRadius: 4, /** Width of the ring cut out of a native tab bar icon around its status dot, as the JS tab bar's dot border does. */ nativeTabIconDotCutout: 2, - /** Gap between the glyph and the label drawn under it in an iOS native tab bar icon. */ + /** Gap between the glyph and the label drawn under it, or beside it in landscape, in an iOS native tab bar icon. */ nativeTabIconLabelGap: 6, /** Space between the screen edge and the first tab of the iOS 26 floating tab bar. */ iosNativeTabBarHorizontalInset: 25, From 60f8c14f4b6a872c34b569854b64acfaec5c472f Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 15 Oct 2026 14:34:22 +0200 Subject: [PATCH 745/781] test: add useTabRootScrollProps mock --- tests/unit/SidebarLinksTest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/SidebarLinksTest.tsx b/tests/unit/SidebarLinksTest.tsx index 07fde6da432d..9c0758fef3f1 100644 --- a/tests/unit/SidebarLinksTest.tsx +++ b/tests/unit/SidebarLinksTest.tsx @@ -28,6 +28,7 @@ jest.mock('@hooks/useResponsiveLayout', () => ({ default: () => ({shouldUseNarrowLayout: mockShouldUseNarrowLayout, isInLandscapeMode: false}), })); +jest.mock('@components/Navigation/TabBarBottomContent/useTabRootScrollProps', () => () => ({})); jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({})})); jest.mock('@hooks/useStyleUtils', () => ({__esModule: true, default: () => ({getSafeAreaMargins: () => ({marginBottom: 0})})})); jest.mock('@hooks/useSidebarOrderedReports', () => ({useSidebarOrderedReportsActions: () => ({setStickyReportID: jest.fn()})})); From 799dacfe9a8ff5636e9fe27c2d50ec5100754987 Mon Sep 17 00:00:00 2001 From: Hans Date: Thu, 15 Oct 2026 19:58:33 +0700 Subject: [PATCH 746/781] fix latest comment --- src/libs/actions/Welcome/OnboardingFlow.ts | 6 +-- tests/unit/OnboardingFlowTest.ts | 46 +++++++++++++++++----- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 29275a7037d2..0a50edcdc43e 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -144,11 +144,11 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI CONST.ONBOARDING_CHOICES.TRACK_PERSONAL, ]); } - // A successful direct add can leave the account's public-domain flag stale until the next account update. - const shouldResumePrivateDomainValidation = currentOnboardingValues?.shouldValidate === false && initialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route); + // Preserve onboarding progress after a direct add while the public domain flag is stale. + const shouldResumeOnboardingAfterDirectAdd = currentOnboardingValues?.shouldValidate === false && state?.routes?.at(-1)?.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR; // A validated account has no reason to be on the onboarding "add work email" screen. - if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated && !shouldResumePrivateDomainValidation) { + if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated && !shouldResumeOnboardingAfterDirectAdd) { return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } diff --git a/tests/unit/OnboardingFlowTest.ts b/tests/unit/OnboardingFlowTest.ts index 9509b981f763..8e71267e8d38 100644 --- a/tests/unit/OnboardingFlowTest.ts +++ b/tests/unit/OnboardingFlowTest.ts @@ -219,14 +219,16 @@ describe('OnboardingFlow', () => { expect(path).toBe('/onboarding/private-domain'); }); - it.each([ - {shouldValidate: false, isAccountValidated: false, expectedPath: savedPrivateDomainPath}, - {shouldValidate: undefined, isAccountValidated: false, expectedPath: '/onboarding/work-email'}, - {shouldValidate: true, isAccountValidated: false, expectedPath: '/onboarding/work-email'}, - {shouldValidate: false, isAccountValidated: true, expectedPath: '/onboarding/purpose'}, - ])( - 'resumes at $expectedPath after resetting merge flags with shouldValidate=$shouldValidate and validated=$isAccountValidated', - ({shouldValidate, isAccountValidated, expectedPath}) => { + it.each( + [savedPrivateDomainPath, `/${ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()}`, `/${ROUTES.ONBOARDING_PURPOSE.getRoute()}`].flatMap((savedPath) => [ + {savedPath, shouldValidate: false, isAccountValidated: false, expectedPath: savedPath}, + {savedPath, shouldValidate: undefined, isAccountValidated: false, expectedPath: '/onboarding/work-email'}, + {savedPath, shouldValidate: true, isAccountValidated: false, expectedPath: '/onboarding/work-email'}, + {savedPath, shouldValidate: false, isAccountValidated: true, expectedPath: savedPath === savedPrivateDomainPath ? '/onboarding/purpose' : savedPath}, + ]), + )( + 'resumes $savedPath at $expectedPath after resetting merge flags with shouldValidate=$shouldValidate and validated=$isAccountValidated', + ({savedPath, shouldValidate, isAccountValidated, expectedPath}) => { // Given a previously skipped work email was resubmitted, clearing its merge flags, while the domain flag is stale const onboardingValues = { hasCompletedGuidedSetupFlow: false, @@ -243,17 +245,41 @@ describe('OnboardingFlow', () => { onboardingValues, currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, currentOnboardingCompanySize: undefined, - onboardingInitialPath: savedPrivateDomainPath, + onboardingInitialPath: savedPath, }; // When refreshing restores the saved onboarding route const path = getOnboardingInitialPath(params); - // Then only a successful direct add for an unvalidated account resumes the exact validation route + // Then a successful direct add preserves validation and downstream progress, while pending retries still require work email expect(path).toBe(expectedPath); }, ); + it.each(['/', '/r/123'])('does not bypass work email for a non-onboarding saved path %s after a direct add', (savedPath) => { + // Given a direct-add result with a stale public-domain flag, but no saved onboarding progress + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + isAccountValidated: false, + hasAccessiblePolicies: false, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + shouldValidate: false, + }, + onboardingValues: undefined, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE, + currentOnboardingCompanySize: undefined, + onboardingInitialPath: savedPath, + }; + + // When choosing the initial onboarding step + const path = getOnboardingInitialPath(params); + + // Then unrelated routes do not count as saved onboarding progress + expect(path).toBe('/onboarding/work-email'); + }); + it('should not redirect away from a work-email-validation URL for a public-domain user', () => { const params: GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: true, From c8a26aaa71b9347149a1537f5b30b713417b49e4 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 13:07:59 +0000 Subject: [PATCH 747/781] Update Mobile-Expensify submodule to 99a878c --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 595f5c8f516c..99a878c6f6e1 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 595f5c8f516c7d28f8c412e2658bfd489b0cbba7 +Subproject commit 99a878c6f6e193c31d6fc79323f8825e31cb89e0 From cdeeb73a424b1b6114c23cc35aa3537de15326e9 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 13:40:04 +0000 Subject: [PATCH 748/781] Auto-tighten eslint-seatbelt baseline --- config/eslint/eslint.seatbelt.tsv | 1 - 1 file changed, 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 60c927bec03f..896c0400bb46 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -154,7 +154,6 @@ "../../src/components/DragAndDrop/Provider/index.tsx" "react-hooks/refs" 6 "../../src/components/DraggableList/SortableItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/DropZone/DropZoneWrapper.tsx" "react-hooks/refs" 1 -"../../src/components/DynamicForm/components/DynamicFormFieldRow.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/components/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/EmojiPicker/CategoryShortcutButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/EmojiPicker/EmojiPicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 From e2807e8acbe1308c7b9b4061cbf255d2d5d1a9fb Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 13:43:52 +0000 Subject: [PATCH 749/781] Update Mobile-Expensify submodule to bf0a8d8 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 99a878c6f6e1..bf0a8d8ae17f 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 99a878c6f6e193c31d6fc79323f8825e31cb89e0 +Subproject commit bf0a8d8ae17f82ceed8ce8b5fb1b9362b70b034d From d60a0476018ef842066d1670af4f742054506d4d Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:50:49 +0100 Subject: [PATCH 750/781] add emptyStateButtons param --- src/components/Tables/AgentsTable/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Tables/AgentsTable/index.tsx b/src/components/Tables/AgentsTable/index.tsx index 47b9996163be..835e04641fec 100644 --- a/src/components/Tables/AgentsTable/index.tsx +++ b/src/components/Tables/AgentsTable/index.tsx @@ -1,3 +1,4 @@ +import type {EmptyStateButton} from '@components/EmptyStateComponent/types'; import RenderHTML from '@components/RenderHTML'; import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; @@ -48,6 +49,9 @@ type AgentsTableProps = { ref?: React.Ref> | undefined; agents: AgentRowData[]; + /** Buttons to display on empty view */ + emptyStateButtons?: EmptyStateButton[]; + /** Content rendered above the table header inside the scrollable list */ headerComponent?: React.ReactElement; @@ -63,7 +67,7 @@ type AgentsTableProps = { onRowSelectionChange: (selectedRowKeys: string[]) => void; }; -export default function AgentsTable({ref, agents, headerComponent, canSelectAgents, selectedKeys, shouldShowRoleColumn, onRowSelectionChange}: AgentsTableProps) { +export default function AgentsTable({ref, agents, emptyStateButtons, headerComponent, canSelectAgents, selectedKeys, shouldShowRoleColumn, onRowSelectionChange}: AgentsTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -162,6 +166,7 @@ export default function AgentsTable({ref, agents, headerComponent, canSelectAgen } + buttons={emptyStateButtons} /> From 86c151a8bc5b73cf9acc6fa066c3d2cbfbd70fa3 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:57:59 +0100 Subject: [PATCH 751/781] add canWriteAgents guard before displaying the add button --- src/pages/workspace/WorkspaceAgentsPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspaceAgentsPage.tsx b/src/pages/workspace/WorkspaceAgentsPage.tsx index 1e8e483a68f2..e5ec36b0bc5a 100644 --- a/src/pages/workspace/WorkspaceAgentsPage.tsx +++ b/src/pages/workspace/WorkspaceAgentsPage.tsx @@ -3,6 +3,7 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import RenderHTML from '@components/RenderHTML'; import AgentsTable from '@components/Tables/AgentsTable'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -18,6 +19,7 @@ import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {canMemberWrite} from '@libs/PolicyUtils'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -36,6 +38,7 @@ function WorkspaceAgentsPage({route}: WorkspaceAgentsPageProps) { const policy = usePolicy(route.params.policyID); const {translate} = useLocalize(); const styles = useThemeStyles(); + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const {pageGutter} = useLayoutSpacing(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isBetaEnabled} = usePermissions(); @@ -48,11 +51,12 @@ function WorkspaceAgentsPage({route}: WorkspaceAgentsPageProps) { const hasAgents = agents.length > 0; const canSelectMultiple = shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true; const shouldShowBulkActionsButton = shouldUseNarrowLayout ? canSelectMultiple : selectedAgentKeys.length > 0; + const canWriteAgents = canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.AGENTS); useWorkspaceDocumentTitle(policy?.name, 'agentsPage.title'); // The new agent functionality will be added after CreateCompanyAgent is exposed - const newAgentButton = null; + const newAgentButton = canWriteAgents ? null : null; const bulkActionsButtonOptions: Array>> = [ { From a71b43035d03137d66b0518d428a50694cb49b45 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:01:17 +0100 Subject: [PATCH 752/781] use display add button in header only if we have agents --- src/pages/settings/Agents/AgentsPage.tsx | 4 ++-- src/pages/workspace/WorkspaceAgentsPage.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index 71d8257240c9..892e03ace411 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -43,7 +43,7 @@ function AgentsPage() { useDocumentTitle(translate('agentsPage.title')); - const newAgentButton = ( + const newAgentButton = hasAgents ? ( - ); + ) : null; const bulkActionsButtonOptions: Array>> = [ { diff --git a/src/pages/workspace/WorkspaceAgentsPage.tsx b/src/pages/workspace/WorkspaceAgentsPage.tsx index e5ec36b0bc5a..a4b9bd02f82e 100644 --- a/src/pages/workspace/WorkspaceAgentsPage.tsx +++ b/src/pages/workspace/WorkspaceAgentsPage.tsx @@ -56,7 +56,7 @@ function WorkspaceAgentsPage({route}: WorkspaceAgentsPageProps) { useWorkspaceDocumentTitle(policy?.name, 'agentsPage.title'); // The new agent functionality will be added after CreateCompanyAgent is exposed - const newAgentButton = canWriteAgents ? null : null; + const newAgentButton = canWriteAgents && hasAgents ? null : null; const bulkActionsButtonOptions: Array>> = [ { From 6cee2c51d533490036452dd73c237698d25a3906 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:02:02 +0100 Subject: [PATCH 753/781] mend use display add button in header only if we have agents --- src/hooks/useAgents.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useAgents.ts b/src/hooks/useAgents.ts index ee322551ddf7..087f515d9605 100644 --- a/src/hooks/useAgents.ts +++ b/src/hooks/useAgents.ts @@ -60,6 +60,8 @@ function useAgents() { if (!agent) { return []; } + return []; + const accountID = Number(key.slice(ONYXKEYS.COLLECTION.AGENT.length)); const details = personalDetailsList?.[accountID]; if (!details) { From 30aa2d78c227968b4a1a03d0093bbcb097f5a6a6 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:07:40 +0100 Subject: [PATCH 754/781] add empty state buttons --- src/pages/settings/Agents/AgentsPage.tsx | 11 ++++++++++- src/pages/workspace/WorkspaceAgentsPage.tsx | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx index 892e03ace411..978dc7440f28 100644 --- a/src/pages/settings/Agents/AgentsPage.tsx +++ b/src/pages/settings/Agents/AgentsPage.tsx @@ -45,13 +45,21 @@ function AgentsPage() { const newAgentButton = hasAgents ? ( ) : null; + const emptyStateButtons = [ + { + icon: icons.Plus, + buttonText: translate('agentsPage.newAgent'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + buttonAction: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_NEW.getRoute())), + }, + ]; const bulkActionsButtonOptions: Array>> = [ { @@ -128,6 +136,7 @@ function AgentsPage() { canSelectAgents selectedKeys={selectedAgentKeys} onRowSelectionChange={setSelectedAgents} + emptyStateButtons={emptyStateButtons} /> ); diff --git a/src/pages/workspace/WorkspaceAgentsPage.tsx b/src/pages/workspace/WorkspaceAgentsPage.tsx index a4b9bd02f82e..99ed2e585757 100644 --- a/src/pages/workspace/WorkspaceAgentsPage.tsx +++ b/src/pages/workspace/WorkspaceAgentsPage.tsx @@ -57,6 +57,7 @@ function WorkspaceAgentsPage({route}: WorkspaceAgentsPageProps) { // The new agent functionality will be added after CreateCompanyAgent is exposed const newAgentButton = canWriteAgents && hasAgents ? null : null; + const emptyStateButtons = canWriteAgents ? [] : undefined; const bulkActionsButtonOptions: Array>> = [ { @@ -129,6 +130,7 @@ function WorkspaceAgentsPage({route}: WorkspaceAgentsPageProps) { selectedKeys={selectedAgentKeys} onRowSelectionChange={setSelectedAgents} shouldShowRoleColumn + emptyStateButtons={emptyStateButtons} /> From ce383d0dc46c5199ef14e525f157306268c96946 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:09:21 +0100 Subject: [PATCH 755/781] revert unrelated change --- src/hooks/useAgents.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/hooks/useAgents.ts b/src/hooks/useAgents.ts index 087f515d9605..ee322551ddf7 100644 --- a/src/hooks/useAgents.ts +++ b/src/hooks/useAgents.ts @@ -60,8 +60,6 @@ function useAgents() { if (!agent) { return []; } - return []; - const accountID = Number(key.slice(ONYXKEYS.COLLECTION.AGENT.length)); const details = personalDetailsList?.[accountID]; if (!details) { From 77fbd510da87916ac98d16a101686aba3002ec9f Mon Sep 17 00:00:00 2001 From: "Akinwale Ariwodola (via MelvinBot)" Date: Thu, 15 Oct 2026 14:35:05 +0000 Subject: [PATCH 756/781] Add INT133 help article for inactive Sage Intacct web services user Co-authored-by: Akinwale Ariwodola --- .../INT133-Sync-Error.md | 70 +++++++++++++++++ .../Sync-Errors/INT133-Sync-Error.md | 70 +++++++++++++++++ .../INT133-Sync-Error.md | 75 +++++++++++++++++++ .../Sync-Errors/INT133-Sync-Error.md | 75 +++++++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md create mode 100644 docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md create mode 100644 docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md create mode 100644 docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..759cee3d9083 --- /dev/null +++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md @@ -0,0 +1,70 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means in Sage Intacct and how to reactivate the web services user to restore your connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Expensify Sage Intacct sync error, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins using the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**, preventing the sync from completing. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically indicates: + +- The `xmlgateway_expensify` web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +This issue must be resolved in Sage Intacct and then retried in Expensify. + +### Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Web Services Users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +### Retry the Sync in Expensify + +1. Go to **Settings > Workspaces**. +2. Select your Workspace. +3. Click **Accounting**. +4. Click **Sync Now**. + +Confirm the sync completes successfully. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in Expensify. + +## Does INT133 Mean the Integration Is Disconnected? + +Not necessarily. The connection settings are kept, but Sage Intacct rejects every sync until the web services user is active again. diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..759cee3d9083 --- /dev/null +++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md @@ -0,0 +1,70 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means in Sage Intacct and how to reactivate the web services user to restore your connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Expensify Sage Intacct sync error, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins using the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**, preventing the sync from completing. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically indicates: + +- The `xmlgateway_expensify` web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +This issue must be resolved in Sage Intacct and then retried in Expensify. + +### Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Web Services Users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +### Retry the Sync in Expensify + +1. Go to **Settings > Workspaces**. +2. Select your Workspace. +3. Click **Accounting**. +4. Click **Sync Now**. + +Confirm the sync completes successfully. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in Expensify. + +## Does INT133 Mean the Integration Is Disconnected? + +Not necessarily. The connection settings are kept, but Sage Intacct rejects every sync until the web services user is active again. diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..31d1b2972561 --- /dev/null +++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md @@ -0,0 +1,75 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means and how to reactivate the Sage Intacct web services user to restore the connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Sage Intacct sync authentication failure, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins managing the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**. + +When the web services user is inactive, syncing and exports will not work. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically occurs when: + +- The **xmlgateway_expensify** web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +Follow the steps below to reactivate the web services user and restore the connection. + +--- + +## Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Web Services Users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +## Retry the Sync + +After reactivating the web services user: + +1. Go to **Workspaces > [Workspace Name] > Accounting**. +2. Select **Sync Now**. +3. Confirm the sync completes successfully. + +If the web services user is active, the sync should complete without errors. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access to Fix This? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in the Workspace. + +## Does This Error Affect Exports? + +Yes. Exports and other integration features will not work until the web services user is active again. diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md new file mode 100644 index 000000000000..31d1b2972561 --- /dev/null +++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md @@ -0,0 +1,75 @@ +--- +title: INT133 Sync Error in Sage Intacct Integration +description: Learn what the INT133 sync error means and how to reactivate the Sage Intacct web services user to restore the connection. +keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Sage Intacct sync authentication failure, PL04000076, Workspace Admin +internalScope: Audience is Workspace Admins managing the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122). +--- + +# INT133 Sync Error in Sage Intacct Integration + +If you see the error: + +INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again. + +This means Sage Intacct rejected the connection because the web services user is set to **Inactive**. + +When the web services user is inactive, syncing and exports will not work. + +--- + +## Why the INT133 Sync Error Happens in Sage Intacct + +The INT133 error typically occurs when: + +- The **xmlgateway_expensify** web services user was deactivated in Sage Intacct. +- An administrator changed the user's status to **Inactive** during a user cleanup or license review. +- The web services user that was used to connect the Workspace is no longer in use. + +Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first. + +--- + +## How to Fix the INT133 Sync Error + +Follow the steps below to reactivate the web services user and restore the connection. + +--- + +## Reactivate the Web Services User in Sage Intacct + +1. Log in to Sage Intacct as an administrator. +2. Go to **Company > Web Services Users**. +3. Locate the **xmlgateway_expensify** web services user. +4. Click **Edit**. +5. Change the **Status** to **Active**. +6. Click **Save**. + +If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials. + +--- + +## Retry the Sync + +After reactivating the web services user: + +1. Go to **Workspaces > [Workspace Name] > Accounting**. +2. Select **Sync Now**. +3. Confirm the sync completes successfully. + +If the web services user is active, the sync should complete without errors. + +--- + +# FAQ + +## Do I Need Sage Intacct Admin Access to Fix This? + +Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct. + +## Do I Need to Reenter My Credentials? + +Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in the Workspace. + +## Does This Error Affect Exports? + +Yes. Exports and other integration features will not work until the web services user is active again. From 538eded443ff0746432a697f45505d4864078d9c Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:47:05 +0100 Subject: [PATCH 757/781] disable max-lines lint on Navigation/types.ts --- src/libs/Navigation/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index c34d87c011c2..1ef4c6f0fa8f 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines */ + import type {MultifactorAuthenticationPromptType} from '@components/MultifactorAuthentication/config/types'; import type {SearchQueryString} from '@components/Search/types'; From 6807d3ac8c3666dd0554f1c45f274b5838991d7f Mon Sep 17 00:00:00 2001 From: "Akinwale Ariwodola (via MelvinBot)" Date: Thu, 15 Oct 2026 14:52:22 +0000 Subject: [PATCH 758/781] Update Sage Intacct web services user navigation path in INT133 articles Co-authored-by: Akinwale Ariwodola --- .../Authentication-and-Login-errors/INT133-Sync-Error.md | 2 +- .../Troubleshooting/Sync-Errors/INT133-Sync-Error.md | 2 +- .../Authentication-and-Login-errors/INT133-Sync-Error.md | 2 +- .../Troubleshooting/Sync-Errors/INT133-Sync-Error.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md index 759cee3d9083..fd62cd6ec6db 100644 --- a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md +++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md @@ -34,7 +34,7 @@ This issue must be resolved in Sage Intacct and then retried in Expensify. ### Reactivate the Web Services User in Sage Intacct 1. Log in to Sage Intacct as an administrator. -2. Go to **Company > Web Services Users**. +2. Go to **Company > Admin > Web Services users**. 3. Locate the **xmlgateway_expensify** web services user. 4. Click **Edit**. 5. Change the **Status** to **Active**. diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md index 759cee3d9083..fd62cd6ec6db 100644 --- a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md +++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md @@ -34,7 +34,7 @@ This issue must be resolved in Sage Intacct and then retried in Expensify. ### Reactivate the Web Services User in Sage Intacct 1. Log in to Sage Intacct as an administrator. -2. Go to **Company > Web Services Users**. +2. Go to **Company > Admin > Web Services users**. 3. Locate the **xmlgateway_expensify** web services user. 4. Click **Edit**. 5. Change the **Status** to **Active**. diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md index 31d1b2972561..321331f5b379 100644 --- a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md +++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md @@ -38,7 +38,7 @@ Follow the steps below to reactivate the web services user and restore the conne ## Reactivate the Web Services User in Sage Intacct 1. Log in to Sage Intacct as an administrator. -2. Go to **Company > Web Services Users**. +2. Go to **Company > Admin > Web Services users**. 3. Locate the **xmlgateway_expensify** web services user. 4. Click **Edit**. 5. Change the **Status** to **Active**. diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md index 31d1b2972561..321331f5b379 100644 --- a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md +++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md @@ -38,7 +38,7 @@ Follow the steps below to reactivate the web services user and restore the conne ## Reactivate the Web Services User in Sage Intacct 1. Log in to Sage Intacct as an administrator. -2. Go to **Company > Web Services Users**. +2. Go to **Company > Admin > Web Services users**. 3. Locate the **xmlgateway_expensify** web services user. 4. Click **Edit**. 5. Change the **Status** to **Active**. From 2a7555eca3fd43908c7174c6b788e3ea9bcd7478 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Thu, 15 Oct 2026 11:00:26 -0400 Subject: [PATCH 759/781] Document OIDC workflow permissions --- .github/workflows/buildAndroid.yml | 1 + .github/workflows/buildIOS.yml | 1 + .github/workflows/deploy.yml | 3 +++ .github/workflows/remote-build-android.yml | 2 ++ .github/workflows/remote-build-ios.yml | 2 ++ .github/workflows/testBuild.yml | 1 + .github/workflows/testBuildOnPush.yml | 1 + 7 files changed, 11 insertions(+) diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index 6a50c5be23b5..8bdba2f7e5f3 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -158,6 +158,7 @@ jobs: with: role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading role-duration-seconds: 21600 aws-region: us-east-1 diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index 327fe6a75434..c79ca9aec984 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -239,6 +239,7 @@ jobs: with: role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading role-duration-seconds: 21600 aws-region: us-east-1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 63ace45e33ef..ea6d403ec235 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -201,6 +201,7 @@ jobs: androidBuild: name: Build Android HybridApp needs: [prep] + # Preserve the default token scopes while adding OIDC access for the called workflow permissions: write-all if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildAndroid.yml @@ -430,6 +431,7 @@ jobs: iosBuild: name: Build iOS HybridApp needs: [prep] + # Preserve the default token scopes while adding OIDC access for the called workflow permissions: write-all if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildIOS.yml @@ -705,6 +707,7 @@ jobs: name: Deploy Web to S3 needs: [prep, webBuild, buildStorybook] if: ${{ always() && needs.webBuild.result == 'success' }} + # Preserve the default token scopes while adding OIDC access permissions: write-all runs-on: blacksmith-4vcpu-ubuntu-2404 steps: diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml index 34af4119761e..e24ff55b08b6 100644 --- a/.github/workflows/remote-build-android.yml +++ b/.github/workflows/remote-build-android.yml @@ -47,6 +47,7 @@ jobs: build: needs: [prep, resolveRefs] + # Preserve the default token scopes while adding OIDC access permissions: write-all runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-2vcpu-ubuntu-2404' }} strategy: @@ -85,6 +86,7 @@ jobs: with: role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading role-duration-seconds: 21600 aws-region: us-east-1 diff --git a/.github/workflows/remote-build-ios.yml b/.github/workflows/remote-build-ios.yml index 1bd31521e7d8..f246dcf43b0d 100644 --- a/.github/workflows/remote-build-ios.yml +++ b/.github/workflows/remote-build-ios.yml @@ -47,6 +47,7 @@ jobs: build: needs: [prep, resolveRefs] + # Preserve the default token scopes while adding OIDC access permissions: write-all runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }} strategy: @@ -97,6 +98,7 @@ jobs: with: role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} + # Match the 6h job timeout because Rock does not refresh credentials before uploading role-duration-seconds: 21600 aws-region: us-east-1 diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index b42d6fa5ad35..a29336e8684b 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -63,6 +63,7 @@ jobs: buildApps: needs: [prep, resolveRefs] + # Preserve the default token scopes while adding OIDC access for the called workflow permissions: write-all uses: ./.github/workflows/buildAdHoc.yml with: diff --git a/.github/workflows/testBuildOnPush.yml b/.github/workflows/testBuildOnPush.yml index 068960e457b4..286e2acfc44c 100644 --- a/.github/workflows/testBuildOnPush.yml +++ b/.github/workflows/testBuildOnPush.yml @@ -70,6 +70,7 @@ jobs: buildApps: needs: [prep] + # Preserve the default token scopes while adding OIDC access for the called workflow permissions: write-all uses: ./.github/workflows/buildAdHoc.yml with: From eb2ea7949f1e4923f250811665b61b0a1ce84fa7 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 15:38:36 +0000 Subject: [PATCH 760/781] Update Mobile-Expensify submodule to f5d0668 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index bf0a8d8ae17f..f5d0668a2455 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit bf0a8d8ae17f82ceed8ce8b5fb1b9362b70b034d +Subproject commit f5d0668a245544f6631b28ee527ad99c70a842d7 From 8740a280739cab7269b8ad9803032606ac137e0f Mon Sep 17 00:00:00 2001 From: "Situ Chandra Shil (via MelvinBot)" Date: Thu, 15 Oct 2026 15:41:03 +0000 Subject: [PATCH 761/781] Allow TimePicker inputs to focus in native landscape mode Co-authored-by: Situ Chandra Shil --- src/components/AmountTextInput.tsx | 2 +- src/components/TimePicker/TimePicker.tsx | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 9da049afcd37..c88c15b213ae 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -47,7 +47,7 @@ type AmountTextInputProps = { /** Component to render on the right hand side of the input - only shown if clear button is not rendered */ rightHandSideComponent?: React.ReactNode; -} & Pick; +} & Pick; function AmountTextInput({ formattedAmount, diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index 1feb29272dd7..44e8447d4e8c 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -764,6 +764,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} selection={selectionHour} testID="hourInput" + shouldAllowFocusInLandscapeMode /> {CONST.COLON} {}, shou touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} selection={selectionMinute} testID="minuteInput" + shouldAllowFocusInLandscapeMode /> {showFullFormat && ( <> @@ -811,6 +813,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} selection={selectionSecond} testID="secondInput" + shouldAllowFocusInLandscapeMode /> {CONST.COLON} {}, shou touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} selection={selectionMillisecond} testID="millisecondInput" + shouldAllowFocusInLandscapeMode /> )} From 1fee3d8abf7bfaa45b9ce6c3c29540389967b709 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 15:43:28 +0000 Subject: [PATCH 762/781] Update Mobile-Expensify submodule to 194a6be --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index f5d0668a2455..194a6be946c5 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit f5d0668a245544f6631b28ee527ad99c70a842d7 +Subproject commit 194a6be946c5dec128cf7990a5939f9a954cfff9 From 9fab5c46d6b3101c73de7ada5df7b1d6e1200fe5 Mon Sep 17 00:00:00 2001 From: "Situ Chandra Shil (via MelvinBot)" Date: Thu, 15 Oct 2026 15:49:26 +0000 Subject: [PATCH 763/781] Fix: Wrap long Pick type in AmountTextInput to satisfy Oxfmt Co-authored-by: Situ Chandra Shil --- src/components/AmountTextInput.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index c88c15b213ae..926c78acd9ea 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -47,7 +47,10 @@ type AmountTextInputProps = { /** Component to render on the right hand side of the input - only shown if clear button is not rendered */ rightHandSideComponent?: React.ReactNode; -} & Pick; +} & Pick< + BaseTextInputProps, + 'autoFocus' | 'autoGrowExtraSpace' | 'containerStyles' | 'submitBehavior' | 'ref' | 'onFocus' | 'onBlur' | 'disabled' | 'accessibilityLabel' | 'shouldAllowFocusInLandscapeMode' +>; function AmountTextInput({ formattedAmount, From d77d1c3721165755f90f19c502de3abaecac413e Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 16:36:42 +0000 Subject: [PATCH 764/781] Update version to 9.5.10-0 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 4 ++-- ios/NotificationServiceExtension/Info.plist | 4 ++-- ios/ShareViewController/Info.plist | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8a24c18c5e2a..88223c139e23 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009050904 - versionName "9.5.9-4" + versionCode 1009051000 + versionName "9.5.10-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 43ceca2dc4d6..c349dc5d3d69 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.5.9 + 9.5.10 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.9.4 + 9.5.10.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 20d42fe8fcd9..8aea13d13b50 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.9 + 9.5.10 CFBundleVersion - 9.5.9.4 + 9.5.10.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 29f934ce340a..b4964ddcea2c 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.9 + 9.5.10 CFBundleVersion - 9.5.9.4 + 9.5.10.0 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index aeae67497526..8873693acad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.9-4", + "version": "9.5.10-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.9-4", + "version": "9.5.10-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 3c8b2785ac90..820a79125aeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.9-4", + "version": "9.5.10-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From a3d62cc65bc6be59cb15374a89d7de827b94793e Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 16:36:42 +0000 Subject: [PATCH 765/781] Update Mobile-Expensify submodule version to 9.5.10-0 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 194a6be946c5..0c4b36fc35cf 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 194a6be946c5dec128cf7990a5939f9a954cfff9 +Subproject commit 0c4b36fc35cf9aab536658596a457458b39fe027 From 198b7a031e039b1e0cd70af5b8ca9573775dbca0 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 17:02:26 +0000 Subject: [PATCH 766/781] Auto-tighten eslint-seatbelt baseline --- config/eslint/eslint.seatbelt.tsv | 1 - 1 file changed, 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 66ff091e16fd..52691e339eef 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -830,7 +830,6 @@ "../../src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 From 03b6d2460a5763a2ab8bc3f89b9cd4f916552d8b Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Thu, 15 Oct 2026 18:29:41 +0000 Subject: [PATCH 767/781] Render Merchant chip popup controls directly so they size to content on native Co-authored-by: truph01 --- .../AdvancedFilters/MerchantFilterContent.tsx | 9 +-- .../FilterDropdowns/MerchantFilterPopup.tsx | 72 ++++++++++++------ src/libs/SearchQueryUtils.ts | 11 +++ tests/ui/MerchantFilterPopupTest.tsx | 75 +++++++++++++++++++ tests/unit/Search/SearchQueryUtilsTest.ts | 30 ++++++++ 5 files changed, 168 insertions(+), 29 deletions(-) diff --git a/src/components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent.tsx index badf05abfa88..3252c8e34650 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent.tsx @@ -5,8 +5,9 @@ import MerchantMatchTypeSelector from '@components/Search/FilterComponents/Merch import useThemeStyles from '@hooks/useThemeStyles'; +import {getMerchantFilterFormValues} from '@libs/SearchQueryUtils'; + import CONST from '@src/CONST'; -import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; import type {MerchantMatchType, SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -69,11 +70,7 @@ function MerchantFilterContent({ const [merchantOperator, setMerchantOperator] = useState(initialMerchantOperator ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); const updateMerchantFilter = (newValue: string | undefined, newIsNegated: boolean) => { - onChange({ - [FILTER_KEYS.MERCHANT]: newIsNegated ? undefined : newValue, - [FILTER_KEYS.MERCHANT_NOT]: newIsNegated ? newValue : undefined, - [FILTER_KEYS.MERCHANT_OPERATOR]: newIsNegated ? undefined : merchantOperator, - }); + onChange(getMerchantFilterFormValues(newValue, newIsNegated, merchantOperator)); }; // A negated Merchant filter always uses the not-equal operator, so the match type is hidden. diff --git a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx index 33b6faed72d1..97af4af6e4ae 100644 --- a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx +++ b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx @@ -1,18 +1,22 @@ /** * Edits the Merchant filter value and match type from its search filter chip. */ -import MerchantFilterContent from '@components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent'; +import MerchantMatchTypeSelector from '@components/Search/FilterComponents/MerchantMatchTypeSelector'; +import NegatableFilter from '@components/Search/FilterComponents/NegatableFilter'; +import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; +import TextInput from '@components/TextInput'; -import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getMerchantFilterFormValues} from '@libs/SearchQueryUtils'; import {getFilterNegatableValue} from '@libs/SearchUIUtils'; -import type CONST from '@src/CONST'; +import CONST from '@src/CONST'; import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; -import type {SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; +import type {MerchantMatchType, SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; -import React from 'react'; +import React, {useState} from 'react'; +import {View} from 'react-native'; import type {PopoverComponentProps} from './FilterPopupButton'; @@ -31,36 +35,58 @@ type MerchantFilterPopupProps = { updateFilterForm: (value: Partial) => void; }; +// The controls are rendered directly in BasePopup, like TextFilterPopup, because the popover sizes to its content. +// The advanced filters page content uses a flex ScrollView, which collapses to zero height in a content-sized popover on native. function MerchantFilterPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay}: MerchantFilterPopupProps) { - const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isNegated, value} = getFilterNegatableValue(baseFilterKey, values); - const applySentryLabel = `Search-FilterPopupApply-${baseFilterKey}`; - const applyChanges = (newValues: Partial) => { - updateFilterForm(newValues); + const {isNegated: initialIsNegated, value: initialValue} = getFilterNegatableValue(baseFilterKey, values); + const [isNegated, setIsNegated] = useState(initialIsNegated); + const [value, setValue] = useState(initialValue); + const [merchantOperator, setMerchantOperator] = useState(values?.[FILTER_KEYS.MERCHANT_OPERATOR] ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + + const error = useTextFilterValidation(baseFilterKey, value); + + const applyChanges = () => { + if (error) { + return; + } + updateFilterForm(getMerchantFilterFormValues(value, isNegated, merchantOperator)); closeOverlay(); }; - // The content renders its own padded Apply button, so the popup only provides the label and the spacing ActionButtons would add. return ( - + onNegationChange={setIsNegated} + > + + + {/* A negated Merchant filter always uses the not-equal operator, so the match type is hidden. */} + {!isNegated && ( + + + + )} ); } diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 5f3f9868ec96..a6fb18b658c4 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -3115,6 +3115,16 @@ function getFilterFormValues { + return { + ...getFilterFormValues(CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, value, isNegated), + [FILTER_KEYS.MERCHANT_OPERATOR]: isNegated ? undefined : merchantOperator, + }; +} + /** * Checks whether a query still matches a default query: it contains all of the default query's filter keys and has the same type. * Used to detect when a query no longer represents a given default/suggested search (e.g. a filter was removed). @@ -3217,6 +3227,7 @@ export { isFilterNegatable, removeNegation, getFilterFormValues, + getMerchantFilterFormValues, getFilterFromQuery, getValidLastQuery, doesQueryMatchDefaultFilterKeysAndType, diff --git a/tests/ui/MerchantFilterPopupTest.tsx b/tests/ui/MerchantFilterPopupTest.tsx index 78f6ef3e97b9..ed9e48bf23dc 100644 --- a/tests/ui/MerchantFilterPopupTest.tsx +++ b/tests/ui/MerchantFilterPopupTest.tsx @@ -39,4 +39,79 @@ describe('MerchantFilterPopup', () => { }); expect(closeOverlay).toHaveBeenCalledTimes(1); }); + + it('renders the input and match type options in the popup', () => { + // Given a Merchant chip popup with a stored filter + render( + + + , + ); + + // Then the input and both match types render inside the popup, because the popover sizes to its content and must not collapse them + expect(screen.getByDisplayValue('I')).toBeOnTheScreen(); + expect(screen.getByText('search.filters.merchant.contains')).toBeOnTheScreen(); + expect(screen.getByText('search.filters.merchant.equalTo')).toBeOnTheScreen(); + }); + + it('applies the selected match type', () => { + // Given a Merchant chip popup with a stored Contains filter + const updateFilterForm = jest.fn(); + render( + + + , + ); + + // When the user selects Equal to and applies the filter + fireEvent.press(screen.getByText('search.filters.merchant.equalTo')); + fireEvent.press(screen.getByText('common.apply')); + + // Then the popup submits the Equal to match type + expect(updateFilterForm).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'I', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + }); + + it('hides the match type and clears it when the filter is negated', () => { + // Given a Merchant chip popup with a stored Equal to filter + const updateFilterForm = jest.fn(); + render( + + + , + ); + + // When the user negates the filter and applies it + fireEvent.press(screen.getByText('search.filters.filterType.is.negative')); + fireEvent.press(screen.getByText('common.apply')); + + // Then the match type is hidden and cleared, because a negated Merchant filter always uses the not-equal operator + expect(screen.queryByText('search.filters.merchant.equalTo')).not.toBeOnTheScreen(); + expect(updateFilterForm).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: undefined, + [FILTER_KEYS.MERCHANT_NOT]: 'I', + [FILTER_KEYS.MERCHANT_OPERATOR]: undefined, + }); + }); }); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index f94025165124..5492620b2e4f 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -24,6 +24,7 @@ import { getFilterDisplayValue, getFilterFormValues, getFilterFromQuery, + getMerchantFilterFormValues, getFooterSelectionFromQuery, getQueryWithFooterSelection, getQueryWithoutFooterSelections, @@ -61,6 +62,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; import type * as OnyxTypes from '@src/types/onyx'; import type {Connections} from '@src/types/onyx/Policy'; @@ -5368,6 +5370,34 @@ describe('SearchQueryUtils', () => { }); }); + describe('getMerchantFilterFormValues', () => { + it('keeps the match type when not negated', () => { + // Given a Merchant filter that is not negated + // When the form values are built + const result = getMerchantFilterFormValues('coffee', false, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + + // Then the match type is kept, because it decides between Contains and Equal to + expect(result).toEqual({ + [FILTER_KEYS.MERCHANT]: 'coffee', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + }); + + it('clears the match type when negated', () => { + // Given a negated Merchant filter + // When the form values are built + const result = getMerchantFilterFormValues('coffee', true, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + + // Then the match type is cleared, because a negated Merchant filter always uses the not-equal operator + expect(result).toEqual({ + [FILTER_KEYS.MERCHANT]: undefined, + [FILTER_KEYS.MERCHANT_NOT]: 'coffee', + [FILTER_KEYS.MERCHANT_OPERATOR]: undefined, + }); + }); + }); + describe('getFilterFromQuery', () => { test('returns all comma-separated values for a non-negated filter', () => { const queryJSON = buildSearchQueryJSON('type:expense policyID:123,456'); From 49008aeb8d10eb23565cd98e4dda22e464548c19 Mon Sep 17 00:00:00 2001 From: Jasper Huang Date: Thu, 15 Oct 2026 12:23:25 -0700 Subject: [PATCH 768/781] feat(settings): remove beta gate for human support --- src/pages/settings/HelpPage/HelpPage.tsx | 30 ++++++++++-------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/pages/settings/HelpPage/HelpPage.tsx b/src/pages/settings/HelpPage/HelpPage.tsx index f9a2870b5b26..97a6d58d298b 100644 --- a/src/pages/settings/HelpPage/HelpPage.tsx +++ b/src/pages/settings/HelpPage/HelpPage.tsx @@ -13,7 +13,6 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; -import usePermissions from '@hooks/usePermissions'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -65,7 +64,6 @@ function HelpPage() { const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {openConciergeAnywhere} = useOpenConciergeAnywhere(); - const {isBetaEnabled} = usePermissions(); const {isOffline} = useNetwork(); const openSupportTicketOrConcierge = () => { @@ -212,22 +210,20 @@ function HelpPage() { sentryLabel: CONST.SENTRY_LABEL.SETTINGS_HELP.HELP_DOCS, }; - const talkToAHumanItem = isBetaEnabled(CONST.BETAS.SUPPORT_TICKET) - ? { - key: 'initialSettingsPage.talkToAHuman', - title: translate('initialSettingsPage.talkToAHuman'), - description: translate('initialSettingsPage.helpPage.talkToAHumanDescription'), - icon: icons.ChatBubbles, - iconType: CONST.ICON_TYPE_ICON, - onPress: openSupportTicketOrConcierge, - isDisabled: isOffline, - shouldShowRightIcon: true, - wrapperStyle: [styles.sectionMenuItemTopDescription], - sentryLabel: CONST.SENTRY_LABEL.SETTINGS_HELP.SUPPORT_TICKET, - } - : null; + const talkToAHumanItem = { + key: 'initialSettingsPage.talkToAHuman', + title: translate('initialSettingsPage.talkToAHuman'), + description: translate('initialSettingsPage.helpPage.talkToAHumanDescription'), + icon: icons.ChatBubbles, + iconType: CONST.ICON_TYPE_ICON, + onPress: openSupportTicketOrConcierge, + isDisabled: isOffline, + shouldShowRightIcon: true, + wrapperStyle: [styles.sectionMenuItemTopDescription], + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_HELP.SUPPORT_TICKET, + }; - const moreResourcesItems = [...(hasActiveItem ? [] : [conciergeItem]), ...(talkToAHumanItem ? [talkToAHumanItem] : []), helpSiteItem]; + const moreResourcesItems = [...(hasActiveItem ? [] : [conciergeItem]), talkToAHumanItem, helpSiteItem]; useEffect(() => { openHelpPage(); From b58ad96d39f455dcacb9f8c501d024706bf10ba8 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 20:03:22 +0000 Subject: [PATCH 769/781] Update version to 9.5.10-1 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 88223c139e23..c87a3ea66604 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009051000 - versionName "9.5.10-0" + versionCode 1009051001 + versionName "9.5.10-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index c349dc5d3d69..64a11cc04ec0 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.10.0 + 9.5.10.1 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 8aea13d13b50..7db2d7630729 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.10 CFBundleVersion - 9.5.10.0 + 9.5.10.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index b4964ddcea2c..087e1c0bbc46 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.5.10 CFBundleVersion - 9.5.10.0 + 9.5.10.1 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 8873693acad1..abfbeed51cc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.10-0", + "version": "9.5.10-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.10-0", + "version": "9.5.10-1", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 820a79125aeb..282bebb240a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.10-0", + "version": "9.5.10-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From b9a952165248cf929e8f2b6840a679429d57cd25 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 20:03:22 +0000 Subject: [PATCH 770/781] Update Mobile-Expensify submodule version to 9.5.10-1 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 0c4b36fc35cf..218a41d7d25a 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 0c4b36fc35cf9aab536658596a457458b39fe027 +Subproject commit 218a41d7d25af8bad817456f5d900aee13771405 From 98bbd8fa3821a29c54c19d418880910d9bb74b03 Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Thu, 15 Oct 2026 20:12:40 +0000 Subject: [PATCH 771/781] Keep Merchant chip popup controls scrollable when they don't fit, such as in landscape Co-authored-by: truph01 --- .../FilterDropdowns/MerchantFilterPopup.tsx | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx index 97af4af6e4ae..60cd109cc337 100644 --- a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx +++ b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx @@ -1,12 +1,15 @@ /** * Edits the Merchant filter value and match type from its search filter chip. */ +import ScrollView from '@components/ScrollView'; import MerchantMatchTypeSelector from '@components/Search/FilterComponents/MerchantMatchTypeSelector'; import NegatableFilter from '@components/Search/FilterComponents/NegatableFilter'; import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; import TextInput from '@components/TextInput'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {getMerchantFilterFormValues} from '@libs/SearchQueryUtils'; import {getFilterNegatableValue} from '@libs/SearchUIUtils'; @@ -37,8 +40,11 @@ type MerchantFilterPopupProps = { // The controls are rendered directly in BasePopup, like TextFilterPopup, because the popover sizes to its content. // The advanced filters page content uses a flex ScrollView, which collapses to zero height in a content-sized popover on native. +// A content-sized ScrollView under a max height keeps the controls reachable when they don't fit, for example in landscape. function MerchantFilterPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay}: MerchantFilterPopupProps) { const styles = useThemeStyles(); + const {windowHeight} = useWindowDimensions(); + const isInLandscapeMode = useIsInLandscapeMode(); const {isNegated: initialIsNegated, value: initialValue} = getFilterNegatableValue(baseFilterKey, values); const [isNegated, setIsNegated] = useState(initialIsNegated); @@ -60,33 +66,36 @@ function MerchantFilterPopup({baseFilterKey, values, label, updateFilterForm, cl label={label} onApply={applyChanges} applySentryLabel={`Search-FilterPopupApply-${baseFilterKey}`} + style={[styles.getPopoverMaxHeight(windowHeight, isInLandscapeMode)]} shouldSubmitOnEnter > - - - - {/* A negated Merchant filter always uses the not-equal operator, so the match type is hidden. */} - {!isNegated && ( - - + + - - )} + + {/* A negated Merchant filter always uses the not-equal operator, so the match type is hidden. */} + {!isNegated && ( + + + + )} + ); } From c107795b72f05de05e97e7d3952e65ef726a1aa9 Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Thu, 15 Oct 2026 20:29:51 +0000 Subject: [PATCH 772/781] Let the Merchant chip popup ScrollView shrink so Apply stays visible Co-authored-by: truph01 --- .../Search/FilterDropdowns/MerchantFilterPopup.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx index 60cd109cc337..6c6535839653 100644 --- a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx +++ b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx @@ -40,7 +40,8 @@ type MerchantFilterPopupProps = { // The controls are rendered directly in BasePopup, like TextFilterPopup, because the popover sizes to its content. // The advanced filters page content uses a flex ScrollView, which collapses to zero height in a content-sized popover on native. -// A content-sized ScrollView under a max height keeps the controls reachable when they don't fit, for example in landscape. +// A content-sized ScrollView (flexShrink1, not flex1) under a max height only shrinks and scrolls when the controls don't fit, +// for example in landscape or with the keyboard open, so the Apply button stays visible. function MerchantFilterPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay}: MerchantFilterPopupProps) { const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); @@ -69,7 +70,10 @@ function MerchantFilterPopup({baseFilterKey, values, label, updateFilterForm, cl style={[styles.getPopoverMaxHeight(windowHeight, isInLandscapeMode)]} shouldSubmitOnEnter > - + Date: Thu, 15 Oct 2026 20:50:01 +0000 Subject: [PATCH 773/781] Cap the Merchant chip popup above the keyboard so Apply stays visible Co-authored-by: truph01 --- .../Search/FilterDropdowns/MerchantFilterPopup.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx index 6c6535839653..0aa56da3d8ab 100644 --- a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx +++ b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx @@ -8,9 +8,12 @@ import useTextFilterValidation from '@components/Search/hooks/useTextFilterValid import TextInput from '@components/TextInput'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useKeyboardState from '@hooks/useKeyboardState'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {BOTTOM_SHEET_TOP_PADDING} from '@libs/getBottomSheetHeight'; import {getMerchantFilterFormValues} from '@libs/SearchQueryUtils'; import {getFilterNegatableValue} from '@libs/SearchUIUtils'; @@ -46,6 +49,12 @@ function MerchantFilterPopup({baseFilterKey, values, label, updateFilterForm, cl const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); const isInLandscapeMode = useIsInLandscapeMode(); + const {isKeyboardActive, keyboardActiveHeight} = useKeyboardState(); + const {paddingTop: safeAreaTop} = useSafeAreaPaddings(true); + + // The popover avoids the keyboard by padding its bottom, so while the keyboard is open only the room above it is available. + const {maxHeight: popoverMaxHeight} = styles.getPopoverMaxHeight(windowHeight, isInLandscapeMode); + const maxHeight = isKeyboardActive ? Math.min(popoverMaxHeight, windowHeight - keyboardActiveHeight - safeAreaTop - BOTTOM_SHEET_TOP_PADDING) : popoverMaxHeight; const {isNegated: initialIsNegated, value: initialValue} = getFilterNegatableValue(baseFilterKey, values); const [isNegated, setIsNegated] = useState(initialIsNegated); @@ -67,7 +76,7 @@ function MerchantFilterPopup({baseFilterKey, values, label, updateFilterForm, cl label={label} onApply={applyChanges} applySentryLabel={`Search-FilterPopupApply-${baseFilterKey}`} - style={[styles.getPopoverMaxHeight(windowHeight, isInLandscapeMode)]} + style={[{maxHeight}]} shouldSubmitOnEnter > Date: Thu, 15 Oct 2026 21:08:37 +0000 Subject: [PATCH 774/781] Update version to 9.5.11-0 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 4 ++-- ios/NotificationServiceExtension/Info.plist | 4 ++-- ios/ShareViewController/Info.plist | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index c87a3ea66604..f91e1f3b4383 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009051001 - versionName "9.5.10-1" + versionCode 1009051100 + versionName "9.5.11-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 64a11cc04ec0..52d33b8b2c37 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.5.10 + 9.5.11 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.10.1 + 9.5.11.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 7db2d7630729..09a7c9084b25 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.10 + 9.5.11 CFBundleVersion - 9.5.10.1 + 9.5.11.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 087e1c0bbc46..fc264a430ea6 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.10 + 9.5.11 CFBundleVersion - 9.5.10.1 + 9.5.11.0 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index abfbeed51cc2..450df461ab92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.10-1", + "version": "9.5.11-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.10-1", + "version": "9.5.11-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 282bebb240a6..85c8950a3e25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.10-1", + "version": "9.5.11-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 8bf7ec10f0fc6d00c3109204625f44504dc93485 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 21:08:37 +0000 Subject: [PATCH 775/781] Update Mobile-Expensify submodule version to 9.5.11-0 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 218a41d7d25a..8db2e2f0cc76 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 218a41d7d25af8bad817456f5d900aee13771405 +Subproject commit 8db2e2f0cc7676acc6cf5a24e9a9e7ba7e04e2b4 From 44ff6357c6a51968219e3bf833bef97660da9682 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 21:35:10 +0000 Subject: [PATCH 776/781] Auto-tighten eslint-seatbelt baseline --- config/eslint/eslint.seatbelt.tsv | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 96af4bae696d..7be147cfcd32 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -14,7 +14,6 @@ "../../.github/actions/javascript/failureNotifier/failureNotifier.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/actions/javascript/formatCodeCovComment/formatCodeCovComment.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getAndroidRolloutPercentage/getAndroidRolloutPercentage.ts" "rulesdir/no-default-id-values" 1 -"../../.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../.github/actions/javascript/getPullRequestDetails/getPullRequestDetails.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../.github/actions/javascript/getPullRequestIncrementalChanges/getPullRequestIncrementalChanges.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 @@ -84,7 +83,6 @@ "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "react-hooks/refs" 1 "../../src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AutoCompleteSuggestions/index.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/components/AutoSubmitModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/AutoUpdateTime.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2 "../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1 @@ -121,7 +119,6 @@ "../../src/components/Composer/implementation/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Composer/implementation/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Composer/implementation/index.tsx" "react-hooks/set-state-in-effect" 2 -"../../src/components/ConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ConnectionLayout.tsx" "no-restricted-imports" 1 "../../src/components/ConnectToMergeFlow/index.ios.tsx" "no-restricted-imports" 1 @@ -155,7 +152,6 @@ "../../src/components/DraggableList/SortableItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/DropZone/DropZoneWrapper.tsx" "react-hooks/refs" 1 "../../src/components/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/EmojiPicker/CategoryShortcutButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/EmojiPicker/EmojiPicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/EmojiPicker/EmojiPicker.tsx" "react-hooks/refs" 3 "../../src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1246,14 +1242,14 @@ "../../src/pages/SetDefaultWorkspacePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AboutPage/AboutPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AboutPage/AboutPage.tsx" "react-hooks/refs" 1 -"../../src/pages/settings/Agents/DynamicAddAgentPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/AgentsPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/DynamicAddAgentPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/DynamicEditAgentPage.tsx" "no-restricted-imports" 1 +"../../src/pages/settings/Agents/DynamicNewAgentPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/DynamicNewAgentPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/AppDownloadLinks.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Copilot/CopilotPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Copilot/CopilotPage.tsx" "no-restricted-imports" 1 From e38e11d813134cb3dc53a4cc598d97df24562b29 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 21:39:55 +0000 Subject: [PATCH 777/781] Update version to 9.5.12-0 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 4 ++-- ios/NotificationServiceExtension/Info.plist | 4 ++-- ios/ShareViewController/Info.plist | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index f91e1f3b4383..adee017af7f6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009051100 - versionName "9.5.11-0" + versionCode 1009051200 + versionName "9.5.12-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 52d33b8b2c37..fb952548093e 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.5.11 + 9.5.12 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.5.11.0 + 9.5.12.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 09a7c9084b25..1400a9e0b310 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.11 + 9.5.12 CFBundleVersion - 9.5.11.0 + 9.5.12.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index fc264a430ea6..f5d8b19ab43b 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.5.11 + 9.5.12 CFBundleVersion - 9.5.11.0 + 9.5.12.0 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 450df461ab92..a2bb430fc1df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.5.11-0", + "version": "9.5.12-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.5.11-0", + "version": "9.5.12-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 85c8950a3e25..5ebd25830977 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.5.11-0", + "version": "9.5.12-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From fbd1b55b11b1bda6eb009001e9cbae38a0ff9fcb Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 21:39:55 +0000 Subject: [PATCH 778/781] Update Mobile-Expensify submodule version to 9.5.12-0 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 8db2e2f0cc76..ceee3007b862 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 8db2e2f0cc7676acc6cf5a24e9a9e7ba7e04e2b4 +Subproject commit ceee3007b8629e47896449531de8a39e910042c1 From e658897455c1884a03d2cb24ff07bd556f20ff8b Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 15 Oct 2026 22:41:07 +0000 Subject: [PATCH 779/781] Update Mobile-Expensify submodule to 5525450 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index ceee3007b862..552545018fe1 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit ceee3007b8629e47896449531de8a39e910042c1 +Subproject commit 552545018fe13d715bfe91638702566aa22f463d From 6b9c866fd8e9f92db28ed3d3f38664bdff1fede4 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Fri, 16 Oct 2026 12:17:42 +0200 Subject: [PATCH 780/781] Scope LHN row report names and personal details to the row --- .../LHNOptionsList/LHNOptionsList.tsx | 8 +- .../OptionRowLHN/OptionRowLHNData.tsx | 40 ++++- src/components/LHNOptionsList/types.ts | 4 - src/selectors/ReportAttributes.ts | 14 +- .../SidebarLinksWritePath.perf-test.tsx | 164 ++++++++++++++++++ 5 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 tests/perf-test/SidebarLinksWritePath.perf-test.tsx diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 178a810a657b..cb6115d4a55e 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -2,7 +2,6 @@ import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/us import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -12,14 +11,12 @@ import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; import type {ReactElement} from 'react'; import {useRoute} from '@react-navigation/native'; -import {reportNamesSelector} from '@selectors/ReportAttributes'; import {FlashList} from '@shopify/flash-list'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; @@ -39,8 +36,6 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const {isOffline} = useNetwork(); const flashListRef = useRef>(null); const route = useRoute(); - const [reportNames] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportNamesSelector}); - const styles = useThemeStyles(); const tabRootScrollProps = useTabRootScrollProps(contentContainerStyles, {hasBottomSafeAreaPadding: true}); const estimatedItemSize = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; @@ -72,7 +67,6 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio ); }, - [reportNames, optionMode, shouldDisableFocusOptions, onSelectRow, onLayoutItem], + [optionMode, shouldDisableFocusOptions, onSelectRow, onLayoutItem], ); const extraData = useMemo(() => [data.length, optionMode, isOffline], [data.length, optionMode, isOffline]); diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx index f13d3a2c34ff..d704eab8322b 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx @@ -1,5 +1,4 @@ import type {OptionRowLHNDataProps} from '@components/LHNOptionsList/types'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useReportPreviewSenderID from '@components/ReportActionAvatars/useReportPreviewSenderID'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -8,30 +7,40 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useGetExpensifyCardFromReportAction from '@hooks/useGetExpensifyCardFromReportAction'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import {useReportAttributesByID} from '@hooks/useReportAttributes'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getLastVisibleActionIncludingTransactionThread, getOriginalMessage, isActionableTrackExpense, isInviteOrRemovedAction} from '@libs/ReportActionsUtils'; -import {canUserPerformWriteAction as canUserPerformWriteActionUtil, shouldShowMarkAsDone} from '@libs/ReportUtils'; +import { + getIOUReportIDFromReportActionPreview, + getLastVisibleActionIncludingTransactionThread, + getOriginalMessage, + isActionableTrackExpense, + isInviteOrRemovedAction, +} from '@libs/ReportActionsUtils'; +import {canUserPerformWriteAction as canUserPerformWriteActionUtil, parseMovedTransactionReportIDs, shouldShowMarkAsDone} from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import {getMovedReportID} from '@src/libs/ModifiedExpenseMessage'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportActions as ReportActionsType} from '@src/types/onyx'; -import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; +import type {PersonalDetailsList, Report, ReportActions as ReportActionsType} from '@src/types/onyx'; +import type {ReportAttributesDerivedValue, VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type {ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {reportNamesByIDsSelector} from '@selectors/ReportAttributes'; import React, {useCallback, useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; import OptionRowLHN from './OptionRowLHN'; +const EMPTY_PERSONAL_DETAILS: PersonalDetailsList = {}; + /* * This component gets the data from onyx for the actual * OptionRowLHN component. @@ -42,14 +51,13 @@ function invoiceReceiverPolicyIDSelector(report: OnyxEntry): string | un return report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : undefined; } -function OptionRowLHNData({isOptionFocused = false, fullReport, reportNames, viewMode = 'default', reportID, ...propsToForward}: OptionRowLHNDataProps) { +function OptionRowLHNData({isOptionFocused = false, fullReport, viewMode = 'default', reportID, ...propsToForward}: OptionRowLHNDataProps) { const styles = useThemeStyles(); const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); const isReportFocused = isOptionFocused && currentReportIDValue === reportID; const {translate, localeCompare, dateFnsLocale, formatPhoneNumber} = useLocalize(); const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const {login, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const personalDetails = usePersonalDetails(); const reportAttributes = useReportAttributesByID(reportID); const [oneTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportAttributes?.oneTransactionThreadReportID)}`); @@ -133,6 +141,22 @@ function OptionRowLHNData({isOptionFocused = false, fullReport, reportNames, vie const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`); + const {fromReportID: movedFromReportID, displayReportID: movedDisplayReportID} = lastAction + ? parseMovedTransactionReportIDs(lastAction) + : {fromReportID: undefined, displayReportID: undefined}; + const reportNameIDs = [reportID, lastActionReportID ? String(lastActionReportID) : undefined, movedFromReportID, movedDisplayReportID, getIOUReportIDFromReportActionPreview(lastAction)]; + const [reportNames] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + selector: (attributes: OnyxEntry) => reportNamesByIDsSelector(attributes, reportNameIDs), + }); + + const [personalDetails] = usePersonalDetailsByIDs([ + ...Object.keys(fullReport?.participants ?? {}).map(Number), + fullReport?.ownerAccountID, + fullReport?.managerID, + fullReport?.lastActorAccountID, + lastAction?.actorAccountID, + currentUserAccountID, + ]); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fullReport?.policyID}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); @@ -157,7 +181,7 @@ function OptionRowLHNData({isOptionFocused = false, fullReport, reportNames, vie reportAttributes, oneTransactionThreadReport, reportNameValuePairs, - personalDetails: personalDetails ?? {}, + personalDetails: personalDetails ?? EMPTY_PERSONAL_DETAILS, policy, parentReportAction, conciergeReportID, diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index 4743687bf34d..583b4f8c286e 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -1,7 +1,6 @@ import type CONST from '@src/CONST'; import type {OptionData} from '@src/libs/ReportUtils'; import type {Report} from '@src/types/onyx'; -import type {ReportNamesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {ComponentRef, RefObject} from 'react'; import type {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native'; @@ -38,9 +37,6 @@ type OptionRowLHNDataProps = { fullReport: OnyxEntry; reportID: string; - /** Names of all reports, for the alternate text of rows whose last action points at another report */ - reportNames: ReportNamesDerivedValue | undefined; - /** Toggle between compact and default view */ viewMode?: OptionMode; diff --git a/src/selectors/ReportAttributes.ts b/src/selectors/ReportAttributes.ts index 7d8f390afd8b..ac784f1e0271 100644 --- a/src/selectors/ReportAttributes.ts +++ b/src/selectors/ReportAttributes.ts @@ -19,16 +19,16 @@ const reportNameSelector = (attributes: OnyxEntry, const reportIsEmptySelector = (attributes: OnyxEntry, reportID: string | undefined) => (reportID ? attributes?.reports?.[reportID]?.isEmpty : undefined); -const reportNamesSelector = (attributes: OnyxEntry): ReportNamesDerivedValue | undefined => { - if (!attributes?.reports) { - return undefined; - } +const reportNamesByIDsSelector = (attributes: OnyxEntry, reportIDs: Array): ReportNamesDerivedValue => { const reportNames: ReportNamesDerivedValue = {}; - for (const [reportID, reportAttributes] of Object.entries(attributes.reports)) { - reportNames[reportID] = {reportName: reportAttributes.reportName}; + for (const reportID of reportIDs) { + const reportName = reportNameSelector(attributes, reportID); + if (reportID && reportName !== undefined) { + reportNames[reportID] = {reportName}; + } } return reportNames; }; -export {reportNameSelector, reportIsEmptySelector, reportNamesSelector}; +export {reportNameSelector, reportIsEmptySelector, reportNamesByIDsSelector}; export default reportByIDsSelector; diff --git a/tests/perf-test/SidebarLinksWritePath.perf-test.tsx b/tests/perf-test/SidebarLinksWritePath.perf-test.tsx new file mode 100644 index 000000000000..1101d982c033 --- /dev/null +++ b/tests/perf-test/SidebarLinksWritePath.perf-test.tsx @@ -0,0 +1,164 @@ +import {render, screen} from '@testing-library/react-native'; + +import {setHasRadio} from '@libs/NetworkState'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; + +import Onyx from 'react-native-onyx'; +import {measureAsyncFunction, measureRenders} from 'reassure'; + +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('@libs/Permissions'); +jest.mock('../../src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + isActiveRoute: jest.fn(), + getTopmostReportId: jest.fn(), + getActiveRoute: jest.fn(), + getTopmostReportActionId: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + isDisplayedInModal: jest.fn(() => false), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); +jest.mock('../../src/libs/Navigation/navigationRef', () => ({ + getState: () => ({ + routes: [{name: 'Report'}], + }), + getRootState: () => ({ + routes: [], + }), + addListener: () => () => {}, + isReady: () => true, +})); + +jest.mock('@react-navigation/native'); + +const WRITES_PER_SCENARIO = 10; +const OFFSCREEN_ROOM_ID = '999999101'; +const OFFSCREEN_ROOM_KEY = `${ONYXKEYS.COLLECTION.REPORT}${OFFSCREEN_ROOM_ID}` as const; +const OUTSIDE_ACCOUNT_ID = 999999; + +function buildReports(count: number): ReportCollectionDataSet { + return Object.fromEntries( + Array.from({length: count}, (_, index) => { + const report = { + ...LHNTestUtils.getFakeReport([1, 2], 1, true), + lastMessageText: 'hey', + lastVisibleActionCreated: `2025-01-01 00:00:${String(index % 60).padStart(2, '0')}.${String(index % 1000).padStart(3, '0')}`, + }; + return [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report]; + }), + ); +} + +describe.each([500, 5000])('SidebarLinks write path with %i reports', (reportCount) => { + const reports = buildReports(reportCount); + const firstReportKey = `${ONYXKEYS.COLLECTION.REPORT}${Object.values(reports).at(59)?.reportID}` as const; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + require('@userActions/OnyxDerived').default(); + }); + + beforeEach(async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + setHasRadio(true); + await TestHelper.signInWithTestUser(1, 'email1@test.com', undefined, undefined, 'One'); + await waitForBatchedUpdates(); + + // Given many reports sorted by recent activity and a room older than all of them, so it sits below the rendered window + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], + [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + [OFFSCREEN_ROOM_KEY]: { + ...LHNTestUtils.getFakeReport([1, 2], 1, false), + reportID: OFFSCREEN_ROOM_ID, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, + reportName: '#offscreen', + lastVisibleActionCreated: '2020-01-01 00:00:00.000', + }, + ...reports, + }); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + test('should re-render when a report below the rendered window is renamed', async () => { + let run = 0; + const scenario = async () => { + await screen.findByTestId('lhn-options-list'); + + // When a room no visible row shows gets a new name + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(OFFSCREEN_ROOM_KEY, {reportName: `#offscreen-${run}-${index}`}); + await waitForBatchedUpdates(); + } + run++; + }; + + // Then reassure measures what the rename costs the visible rows + await measureRenders(, {scenario}); + }); + + test('should take wall time to write one visible report', async () => { + // Given a mounted LHN, so the measurement covers subscribers, selectors, comparisons and renders together + render(); + await screen.findByTestId('lhn-options-list'); + let run = 0; + + // When one visible report gets a field update that does not change the order + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(firstReportKey, {lastMessageText: `hey ${run}-${index}`}); + await waitForBatchedUpdates(); + } + run++; + }); + }); + + test('should take wall time to rename a report below the rendered window', async () => { + render(); + await screen.findByTestId('lhn-options-list'); + let run = 0; + + // When a room no visible row shows gets a new name + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(OFFSCREEN_ROOM_KEY, {reportName: `#offscreen-${run}-${index}`}); + await waitForBatchedUpdates(); + } + run++; + }); + }); + + test('should take wall time to write personal details of a user outside the list', async () => { + render(); + await screen.findByTestId('lhn-options-list'); + let run = 0; + + // When a user who is in no LHN report changes their display name + await measureAsyncFunction(async () => { + for (let index = 0; index < WRITES_PER_SCENARIO; index++) { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[OUTSIDE_ACCOUNT_ID]: {accountID: OUTSIDE_ACCOUNT_ID, displayName: `Outside ${run}-${index}`}}); + await waitForBatchedUpdates(); + } + run++; + }); + }); +}); From 7ea35d10d8394782b62a2cddbcd0ac0c721667a2 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Fri, 16 Oct 2026 12:49:13 +0200 Subject: [PATCH 781/781] Cover all accounts and reports read by LHN rows, fix SidebarOrderTest ordering --- .../OptionRowLHN/OptionRowLHNData.tsx | 66 ++++++++++++++++--- src/libs/SidebarUtils.ts | 1 - tests/unit/SidebarOrderTest.ts | 25 +++++-- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx index d704eab8322b..2b95e67b1a7e 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRowLHNData.tsx @@ -15,9 +15,14 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import { getIOUReportIDFromReportActionPreview, getLastVisibleActionIncludingTransactionThread, + getLastVisibleAction, getOriginalMessage, + getReportActionActorAccountID, + isActionableJoinRequest, + isActionableMentionWhisper, isActionableTrackExpense, isInviteOrRemovedAction, + isMoneyRequestAction, } from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction as canUserPerformWriteActionUtil, parseMovedTransactionReportIDs, shouldShowMarkAsDone} from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; @@ -25,7 +30,7 @@ import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import {getMovedReportID} from '@src/libs/ModifiedExpenseMessage'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Report, ReportActions as ReportActionsType} from '@src/types/onyx'; +import type {PersonalDetailsList, Report, ReportAction, ReportActions as ReportActionsType} from '@src/types/onyx'; import type {ReportAttributesDerivedValue, VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Icon} from '@src/types/onyx/OnyxCommon'; @@ -41,15 +46,42 @@ import OptionRowLHN from './OptionRowLHN'; const EMPTY_PERSONAL_DETAILS: PersonalDetailsList = {}; +function invoiceReceiverPolicyIDSelector(report: OnyxEntry): string | undefined { + return report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : undefined; +} + +function invoiceReceiverAccountIDSelector(report: OnyxEntry): number | undefined { + return report?.invoiceReceiver && 'accountID' in report.invoiceReceiver ? report.invoiceReceiver.accountID : undefined; +} + +function getActionAccountIDs(action: OnyxEntry): Array { + if (!action) { + return []; + } + const accountIDs = [action.actorAccountID, getReportActionActorAccountID(action, undefined, undefined)]; + if (isActionableJoinRequest(action)) { + accountIDs.push(getOriginalMessage(action)?.accountID); + } + if (isActionableMentionWhisper(action)) { + accountIDs.push(...(getOriginalMessage(action)?.inviteeAccountIDs ?? [])); + } + return accountIDs; +} + +function getActionReportIDs(action: OnyxEntry): Array { + if (!action) { + return []; + } + const {fromReportID, displayReportID} = parseMovedTransactionReportIDs(action); + return [fromReportID, displayReportID, getIOUReportIDFromReportActionPreview(action)]; +} + /* * This component gets the data from onyx for the actual * OptionRowLHN component. * OptionRowLHN is auto-memoized by React Compiler, so it will * only re-render when the inputs derived from this wrapper change. */ -function invoiceReceiverPolicyIDSelector(report: OnyxEntry): string | undefined { - return report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : undefined; -} function OptionRowLHNData({isOptionFocused = false, fullReport, viewMode = 'default', reportID, ...propsToForward}: OptionRowLHNDataProps) { const styles = useThemeStyles(); @@ -141,20 +173,36 @@ function OptionRowLHNData({isOptionFocused = false, fullReport, viewMode = 'defa const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`); - const {fromReportID: movedFromReportID, displayReportID: movedDisplayReportID} = lastAction - ? parseMovedTransactionReportIDs(lastAction) - : {fromReportID: undefined, displayReportID: undefined}; - const reportNameIDs = [reportID, lastActionReportID ? String(lastActionReportID) : undefined, movedFromReportID, movedDisplayReportID, getIOUReportIDFromReportActionPreview(lastAction)]; + + // getLastMessageTextForReport swaps an IOU CREATE/TRACK last action of a one-transaction report for the report's own last action + const ownLastAction = + oneTransactionThreadReportID && isMoneyRequestAction(lastAction) + ? getLastVisibleAction(reportID, canUserPerformWrite, {}, {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: reportActions ?? undefined}, visibleReportActionsData) + : undefined; + const reportNameIDs = [reportID, lastActionReportID ? String(lastActionReportID) : undefined, ...getActionReportIDs(lastAction), ...getActionReportIDs(ownLastAction)]; const [reportNames] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { selector: (attributes: OnyxEntry) => reportNamesByIDsSelector(attributes, reportNameIDs), }); + const isInvoice = fullReport?.type === CONST.REPORT.TYPE.INVOICE; + const [invoiceChatReceiverAccountID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(isInvoice ? fullReport?.chatReportID : undefined)}`, { + selector: invoiceReceiverAccountIDSelector, + }); + const parentReportActionActorAccountID = getReportActionActorAccountID(parentReportAction, fullReport, fullReport); + const [personalDetails] = usePersonalDetailsByIDs([ ...Object.keys(fullReport?.participants ?? {}).map(Number), fullReport?.ownerAccountID, fullReport?.managerID, fullReport?.lastActorAccountID, - lastAction?.actorAccountID, + parentReportAction?.actorAccountID, + parentReportActionActorAccountID, + invoiceReceiverAccountIDSelector(fullReport), + invoiceChatReceiverAccountID, + ...getActionAccountIDs(lastAction), + ...getActionAccountIDs(ownLastAction), + CONST.ACCOUNT_ID.CONCIERGE, + CONST.ACCOUNT_ID.NOTIFICATIONS, currentUserAccountID, ]); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fullReport?.policyID}`); diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index b332834fda39..4bf0a1485c12 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -974,7 +974,6 @@ function getOptionData({ rawReportName: report.reportName, hasDerivedEntry: !!derivedEntry, derivedReportName: derivedEntry?.reportName, - derivedCount: reportAttributesDerived ? Object.keys(reportAttributesDerived).length : 0, }); } diff --git a/tests/unit/SidebarOrderTest.ts b/tests/unit/SidebarOrderTest.ts index 0b1399f36ce2..c21d5f59384f 100644 --- a/tests/unit/SidebarOrderTest.ts +++ b/tests/unit/SidebarOrderTest.ts @@ -60,6 +60,20 @@ jest.mock('@src/components/ReportActionAvatars', () => { }; }); +// FlashList keeps a row's rendered children when only its index changes, so the index-based testID can go stale after a reorder. The cell's own index prop is always current. +type RenderedElement = ReturnType[number]; + +function getDisplayNamesInListOrder() { + const getCellIndex = (element: RenderedElement) => { + let current: RenderedElement | null = element; + while (current && typeof current.props.index !== 'number') { + current = current.parent; + } + return typeof current?.props.index === 'number' ? current.props.index : Number.MAX_SAFE_INTEGER; + }; + return screen.queryAllByLabelText('Chat member display names').sort((first, second) => getCellIndex(first) - getCellIndex(second)); +} + describe('Sidebar', () => { beforeAll(() => { Onyx.init({ @@ -361,13 +375,12 @@ describe('Sidebar', () => { // Then the order of the reports should be 1 > 3 > 2 // ^--- (1 goes to the front and pushes other two down) .then(() => { - const firstElement = screen.queryByTestId('DisplayNames-0'); - const secondElement = screen.queryByTestId('DisplayNames-1'); - const thirdElement = screen.queryByTestId('DisplayNames-2'); + const displayNames = getDisplayNamesInListOrder(); - expect(firstElement).toHaveTextContent('Email Two'); - expect(secondElement).toHaveTextContent('Email Four'); - expect(thirdElement).toHaveTextContent('Email Three'); + expect(displayNames).toHaveLength(3); + expect(displayNames.at(0)).toHaveTextContent('Email Two'); + expect(displayNames.at(1)).toHaveTextContent('Email Four'); + expect(displayNames.at(2)).toHaveTextContent('Email Three'); }) ); });