diff --git a/.editorconfig b/.editorconfig index 6adecfbf..dd5a8d84 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,7 +7,7 @@ end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -max_line_length = 160 +max_line_length = 180 quote_type = single [test/*] diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml index a7740531..1c4b85b7 100644 --- a/.github/workflows/rebase.yml +++ b/.github/workflows/rebase.yml @@ -3,7 +3,8 @@ name: Automatic Rebase on: [pull_request_target] permissions: - contents: read + contents: write # for ljharb/rebase to push code to rebase + pull-requests: read # for ljharb/rebase to get info about PR jobs: _: diff --git a/CHANGELOG.md b/CHANGELOG.md index 35828d51..0d304ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ +## **6.14.2** +- [Fix] `parse`: mark overflow objects for indexed notation exceeding `arrayLimit` (#546) +- [Fix] `arrayLimit` means max count, not max index, in `combine`/`merge`/`parseArrayValue` +- [Fix] `parse`: throw on `arrayLimit` exceeded with indexed notation when `throwOnLimitExceeded` is true (#529) +- [Fix] `parse`: enforce `arrayLimit` on `comma`-parsed values +- [Fix] `parse`: fix error message to reflect arrayLimit as max index; remove extraneous comments (#545) +- [Robustness] avoid `.push`, use `void` +- [readme] document that `addQueryPrefix` does not add `?` to empty output (#418) +- [readme] clarify `parseArrays` and `arrayLimit` documentation (#543) +- [readme] replace runkit CI badge with shields.io check-runs badge +- [meta] fix changelog typo (`arrayLength` → `arrayLimit`) +- [actions] fix rebase workflow permissions + ## **6.14.1** -- [Fix] ensure arrayLength applies to `[]` notation as well +- [Fix] ensure `arrayLimit` applies to `[]` notation as well - [Fix] `parse`: when a custom decoder returns `null` for a key, ignore that key - [Refactor] `parse`: extract key segment splitting helper - [meta] add threat model diff --git a/README.md b/README.md index 22c411df..5c377393 100644 --- a/README.md +++ b/README.md @@ -282,8 +282,8 @@ var withIndexedEmptyString = qs.parse('a[0]=b&a[1]=&a[2]=c'); assert.deepEqual(withIndexedEmptyString, { a: ['b', '', 'c'] }); ``` -**qs** will also limit specifying indices in an array to a maximum index of `20`. -Any array members with an index of greater than `20` will instead be converted to an object with the index as the key. +**qs** will also limit arrays to a maximum of `20` elements. +Any array members with an index of `20` or greater will instead be converted to an object with the index as the key. This is needed to handle cases when someone sent, for example, `a[999999999]` and it will take significant time to iterate over this huge array. ```javascript @@ -310,7 +310,8 @@ try { When `throwOnLimitExceeded` is set to `false` (default), **qs** will parse up to the specified `arrayLimit` and if the limit is exceeded, the array will instead be converted to an object with the index as the key -To disable array parsing entirely, set `parseArrays` to `false`. +To prevent array syntax (`a[]`, `a[0]`) from being parsed as arrays, set `parseArrays` to `false`. +Note that duplicate keys (e.g. `a=b&a=c`) may still produce arrays when `duplicates` is `'combine'` (the default). ```javascript var noParsingArrays = qs.parse('a[]=b', { parseArrays: false }); @@ -512,6 +513,12 @@ The query string may optionally be prepended with a question mark: assert.equal(qs.stringify({ a: 'b', c: 'd' }, { addQueryPrefix: true }), '?a=b&c=d'); ``` +Note that when the output is an empty string, the prefix will not be added: + +```javascript +assert.equal(qs.stringify({}, { addQueryPrefix: true }), ''); +``` + The delimiter may be overridden with stringify as well: ```javascript @@ -723,7 +730,7 @@ Save time, reduce risk, and improve code health, while paying the maintainers of [downloads-url]: https://npm-stat.com/charts.html?package=qs [codecov-image]: https://codecov.io/gh/ljharb/qs/branch/main/graphs/badge.svg [codecov-url]: https://app.codecov.io/gh/ljharb/qs/ -[actions-image]: https://img.shields.io/endpoint?url=https://github-actions-badge-u3jn4tfpocch.runkit.sh/ljharb/qs +[actions-image]: https://img.shields.io/github/check-runs/ljharb/qs/main [actions-url]: https://github.com/ljharb/qs/actions ## Acknowledgements diff --git a/dist/qs.js b/dist/qs.js index ffb80a24..46568ae4 100644 --- a/dist/qs.js +++ b/dist/qs.js @@ -5,13 +5,13 @@ "use strict";var stringify=require(4),parse=require(3),formats=require(1);module.exports={formats:formats,parse:parse,stringify:stringify}; },{"1":1,"3":3,"4":4}],3:[function(require,module,exports){ -"use strict";var utils=require(5),has=Object.prototype.hasOwnProperty,isArray=Array.isArray,defaults={allowDots:!1,allowEmptyArrays:!1,allowPrototypes:!1,allowSparse:!1,arrayLimit:20,charset:"utf-8",charsetSentinel:!1,comma:!1,decodeDotInKeys:!1,decoder:utils.decode,delimiter:"&",depth:5,duplicates:"combine",ignoreQueryPrefix:!1,interpretNumericEntities:!1,parameterLimit:1e3,parseArrays:!0,plainObjects:!1,strictDepth:!1,strictNullHandling:!1,throwOnLimitExceeded:!1},interpretNumericEntities=function(e){return e.replace(/&#(\d+);/g,function(e,t){return String.fromCharCode(parseInt(t,10))})},parseArrayValue=function(e,t,r){if(e&&"string"==typeof e&&t.comma&&e.indexOf(",")>-1)return e.split(",");if(t.throwOnLimitExceeded&&r>=t.arrayLimit)throw new RangeError("Array limit exceeded. Only "+t.arrayLimit+" element"+(1===t.arrayLimit?"":"s")+" allowed in an array.");return e},isoSentinel="utf8=%26%2310003%3B",charsetSentinel="utf8=%E2%9C%93",parseValues=function parseQueryStringValues(e,t){var r={__proto__:null},i=t.ignoreQueryPrefix?e.replace(/^\?/,""):e;i=i.replace(/%5B/gi,"[").replace(/%5D/gi,"]");var a=t.parameterLimit===1/0?void 0:t.parameterLimit,o=i.split(t.delimiter,t.throwOnLimitExceeded?a+1:a);if(t.throwOnLimitExceeded&&o.length>a)throw new RangeError("Parameter limit exceeded. Only "+a+" parameter"+(1===a?"":"s")+" allowed.");var l,n=-1,s=t.charset;if(t.charsetSentinel)for(l=0;l-1&&(d=isArray(d)?[d]:d),null!==p){var f=has.call(r,p);f&&"combine"===t.duplicates?r[p]=utils.combine(r[p],d,t.arrayLimit,t.plainObjects):f&&"last"!==t.duplicates||(r[p]=d)}}return r},parseObject=function(e,t,r,i){var a=0;if(e.length>0&&"[]"===e[e.length-1]){var o=e.slice(0,-1).join("");a=Array.isArray(t)&&t[o]?t[o].length:0}for(var l=i?t:parseArrayValue(t,r,a),n=e.length-1;n>=0;--n){var s,p=e[n];if("[]"===p&&r.parseArrays)s=utils.isOverflow(l)?l:r.allowEmptyArrays&&(""===l||r.strictNullHandling&&null===l)?[]:utils.combine([],l,r.arrayLimit,r.plainObjects);else{s=r.plainObjects?{__proto__:null}:{};var d="["===p.charAt(0)&&"]"===p.charAt(p.length-1)?p.slice(1,-1):p,c=r.decodeDotInKeys?d.replace(/%2E/g,"."):d,u=parseInt(c,10);r.parseArrays||""!==c?!isNaN(u)&&p!==c&&String(u)===c&&u>=0&&r.parseArrays&&u<=r.arrayLimit?(s=[])[u]=l:"__proto__"!==c&&(s[c]=l):s={0:l}}l=s}return l},splitKeyIntoSegments=function splitKeyIntoSegments(e,t){var r=t.allowDots?e.replace(/\.([^.[]+)/g,"[$1]"):e;if(t.depth<=0){if(!t.plainObjects&&has.call(Object.prototype,r)&&!t.allowPrototypes)return;return[r]}var i=/(\[[^[\]]*])/g,a=/(\[[^[\]]*])/.exec(r),o=a?r.slice(0,a.index):r,l=[];if(o){if(!t.plainObjects&&has.call(Object.prototype,o)&&!t.allowPrototypes)return;l.push(o)}for(var n=0;null!==(a=i.exec(r))&&n-1)return e.split(",");if(t.throwOnLimitExceeded&&r>=t.arrayLimit)throw new RangeError("Array limit exceeded. Only "+t.arrayLimit+" element"+(1===t.arrayLimit?"":"s")+" allowed in an array.");return e},isoSentinel="utf8=%26%2310003%3B",charsetSentinel="utf8=%E2%9C%93",parseValues=function parseQueryStringValues(e,t){var r={__proto__:null},i=t.ignoreQueryPrefix?e.replace(/^\?/,""):e;i=i.replace(/%5B/gi,"[").replace(/%5D/gi,"]");var a=t.parameterLimit===1/0?void 0:t.parameterLimit,o=i.split(t.delimiter,t.throwOnLimitExceeded?a+1:a);if(t.throwOnLimitExceeded&&o.length>a)throw new RangeError("Parameter limit exceeded. Only "+a+" parameter"+(1===a?"":"s")+" allowed.");var l,n=-1,s=t.charset;if(t.charsetSentinel)for(l=0;l-1&&(p=isArray(p)?[p]:p),t.comma&&isArray(p)&&p.length>t.arrayLimit){if(t.throwOnLimitExceeded)throw new RangeError("Array limit exceeded. Only "+t.arrayLimit+" element"+(1===t.arrayLimit?"":"s")+" allowed in an array.");p=utils.combine([],p,t.arrayLimit,t.plainObjects)}if(null!==d){var f=has.call(r,d);f&&"combine"===t.duplicates?r[d]=utils.combine(r[d],p,t.arrayLimit,t.plainObjects):f&&"last"!==t.duplicates||(r[d]=p)}}return r},parseObject=function(e,t,r,i){var a=0;if(e.length>0&&"[]"===e[e.length-1]){var o=e.slice(0,-1).join("");a=Array.isArray(t)&&t[o]?t[o].length:0}for(var l=i?t:parseArrayValue(t,r,a),n=e.length-1;n>=0;--n){var s,d=e[n];if("[]"===d&&r.parseArrays)s=utils.isOverflow(l)?l:r.allowEmptyArrays&&(""===l||r.strictNullHandling&&null===l)?[]:utils.combine([],l,r.arrayLimit,r.plainObjects);else{s=r.plainObjects?{__proto__:null}:{};var p="["===d.charAt(0)&&"]"===d.charAt(d.length-1)?d.slice(1,-1):d,c=r.decodeDotInKeys?p.replace(/%2E/g,"."):p,u=parseInt(c,10),y=!isNaN(u)&&d!==c&&String(u)===c&&u>=0&&r.parseArrays;if(r.parseArrays||""!==c)if(y&&u0?g.join(",")||null:void 0}];else if(isArray(f))S=f;else{var N=Object.keys(g);S=u?N.sort(u):N}var T=l?String(r).replace(/\./g,"%2E"):String(r),O=o&&isArray(g)&&1===g.length?T+"[]":T;if(a&&isArray(g)&&0===g.length)return O+"[]";for(var k=0;k0?c+y:""}; },{"1":1,"46":46,"5":5}],5:[function(require,module,exports){ -"use strict";var formats=require(1),getSideChannel=require(46),has=Object.prototype.hasOwnProperty,isArray=Array.isArray,overflowChannel=getSideChannel(),markOverflow=function markOverflow(e,r){return overflowChannel.set(e,r),e},isOverflow=function isOverflow(e){return overflowChannel.has(e)},getMaxIndex=function getMaxIndex(e){return overflowChannel.get(e)},setMaxIndex=function setMaxIndex(e,r){overflowChannel.set(e,r)},hexTable=function(){for(var e=[],r=0;r<256;++r)e.push("%"+((r<16?"0":"")+r.toString(16)).toUpperCase());return e}(),compactQueue=function compactQueue(e){for(;e.length>1;){var r=e.pop(),t=r.obj[r.prop];if(isArray(t)){for(var o=[],n=0;n=limit?a.slice(i,i+limit):a,f=[],s=0;s=48&&u<=57||u>=65&&u<=90||u>=97&&u<=122||n===formats.RFC1738&&(40===u||41===u)?f[f.length]=l.charAt(s):u<128?f[f.length]=hexTable[u]:u<2048?f[f.length]=hexTable[192|u>>6]+hexTable[128|63&u]:u<55296||u>=57344?f[f.length]=hexTable[224|u>>12]+hexTable[128|u>>6&63]+hexTable[128|63&u]:(s+=1,u=65536+((1023&u)<<10|1023&l.charCodeAt(s)),f[f.length]=hexTable[240|u>>18]+hexTable[128|u>>12&63]+hexTable[128|u>>6&63]+hexTable[128|63&u])}c+=f.join("")}return c},compact=function compact(e){for(var r=[{obj:{o:e},prop:"o"}],t=[],o=0;ot?markOverflow(arrayToObject(a,{plainObjects:o}),a.length-1):a},maybeMap=function maybeMap(e,r){if(isArray(e)){for(var t=[],o=0;o1;){var r=e.pop(),t=r.obj[r.prop];if(isArray(t)){for(var n=[],o=0;ot.arrayLimit)return markOverflow(arrayToObject(e.concat(r),t),n);e[n]=r}else{if(!e||"object"!=typeof e)return[e,r];if(isOverflow(e)){var o=getMaxIndex(e)+1;e[o]=r,setMaxIndex(e,o)}else(t&&(t.plainObjects||t.allowPrototypes)||!has.call(Object.prototype,r))&&(e[r]=!0)}return e}if(!e||"object"!=typeof e){if(isOverflow(r)){for(var a=Object.keys(r),i=t&&t.plainObjects?{__proto__:null,0:e}:{0:e},c=0;ct.arrayLimit?markOverflow(arrayToObject(l,t),l.length-1):l}var f=e;return isArray(e)&&!isArray(r)&&(f=arrayToObject(e,t)),isArray(e)&&isArray(r)?(r.forEach(function(r,n){if(has.call(e,n)){var o=e[n];o&&"object"==typeof o&&r&&"object"==typeof r?e[n]=merge(o,r,t):e[e.length]=r}else e[n]=r}),e):Object.keys(r).reduce(function(e,n){var o=r[n];if(has.call(e,n)?e[n]=merge(e[n],o,t):e[n]=o,isOverflow(r)&&!isOverflow(e)&&markOverflow(e,getMaxIndex(r)),isOverflow(e)){var a=parseInt(n,10);String(a)===n&&a>=0&&a>getMaxIndex(e)&&setMaxIndex(e,a)}return e},f)},assign=function assignSingleSource(e,r){return Object.keys(r).reduce(function(e,t){return e[t]=r[t],e},e)},decode=function(e,r,t){var n=e.replace(/\+/g," ");if("iso-8859-1"===t)return n.replace(/%[0-9a-f]{2}/gi,unescape);try{return decodeURIComponent(n)}catch(e){return n}},limit=1024,encode=function encode(e,r,t,n,o){if(0===e.length)return e;var a=e;if("symbol"==typeof e?a=Symbol.prototype.toString.call(e):"string"!=typeof e&&(a=String(e)),"iso-8859-1"===t)return escape(a).replace(/%u[0-9a-f]{4}/gi,function(e){return"%26%23"+parseInt(e.slice(2),16)+"%3B"});for(var i="",c=0;c=limit?a.slice(c,c+limit):a,f=[],s=0;s=48&&u<=57||u>=65&&u<=90||u>=97&&u<=122||o===formats.RFC1738&&(40===u||41===u)?f[f.length]=l.charAt(s):u<128?f[f.length]=hexTable[u]:u<2048?f[f.length]=hexTable[192|u>>6]+hexTable[128|63&u]:u<55296||u>=57344?f[f.length]=hexTable[224|u>>12]+hexTable[128|u>>6&63]+hexTable[128|63&u]:(s+=1,u=65536+((1023&u)<<10|1023&l.charCodeAt(s)),f[f.length]=hexTable[240|u>>18]+hexTable[128|u>>12&63]+hexTable[128|u>>6&63]+hexTable[128|63&u])}i+=f.join("")}return i},compact=function compact(e){for(var r=[{obj:{o:e},prop:"o"}],t=[],n=0;nt?markOverflow(arrayToObject(a,{plainObjects:n}),a.length-1):a},maybeMap=function maybeMap(e,r){if(isArray(e)){for(var t=[],n=0;n options.arrayLimit) { + if (options.throwOnLimitExceeded) { + throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.'); + } + val = utils.combine([], val, options.arrayLimit, options.plainObjects); + } + if (key !== null) { var existing = has.call(obj, key); if (existing && options.duplicates === 'combine') { @@ -180,17 +187,21 @@ var parseObject = function (chain, val, options, valuesParsed) { var cleanRoot = root.charAt(0) === '[' && root.charAt(root.length - 1) === ']' ? root.slice(1, -1) : root; var decodedRoot = options.decodeDotInKeys ? cleanRoot.replace(/%2E/g, '.') : cleanRoot; var index = parseInt(decodedRoot, 10); - if (!options.parseArrays && decodedRoot === '') { - obj = { 0: leaf }; - } else if ( - !isNaN(index) + var isValidArrayIndex = !isNaN(index) && root !== decodedRoot && String(index) === decodedRoot && index >= 0 - && (options.parseArrays && index <= options.arrayLimit) - ) { + && options.parseArrays; + if (!options.parseArrays && decodedRoot === '') { + obj = { 0: leaf }; + } else if (isValidArrayIndex && index < options.arrayLimit) { obj = []; obj[index] = leaf; + } else if (isValidArrayIndex && options.throwOnLimitExceeded) { + throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.'); + } else if (isValidArrayIndex) { + obj[index] = leaf; + utils.markOverflow(obj, index); } else if (decodedRoot !== '__proto__') { obj[decodedRoot] = leaf; } @@ -230,7 +241,7 @@ var splitKeyIntoSegments = function splitKeyIntoSegments(givenKey, options) { } } - keys.push(parent); + keys[keys.length] = parent; } var i = 0; @@ -244,7 +255,7 @@ var splitKeyIntoSegments = function splitKeyIntoSegments(givenKey, options) { } } - keys.push(segment[1]); + keys[keys.length] = segment[1]; } if (segment) { @@ -252,7 +263,7 @@ var splitKeyIntoSegments = function splitKeyIntoSegments(givenKey, options) { throw new RangeError('Input depth exceeded depth option of ' + options.depth + ' and strictDepth is true'); } - keys.push('[' + key.slice(segment.index) + ']'); + keys[keys.length] = '[' + key.slice(segment.index) + ']'; } return keys; diff --git a/lib/utils.js b/lib/utils.js index 91f555e4..8e10e394 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -30,7 +30,7 @@ var setMaxIndex = function setMaxIndex(obj, maxIndex) { var hexTable = (function () { var array = []; for (var i = 0; i < 256; ++i) { - array.push('%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase()); + array[array.length] = '%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase(); } return array; @@ -46,7 +46,7 @@ var compactQueue = function compactQueue(queue) { for (var j = 0; j < obj.length; ++j) { if (typeof obj[j] !== 'undefined') { - compacted.push(obj[j]); + compacted[compacted.length] = obj[j]; } } @@ -74,7 +74,11 @@ var merge = function merge(target, source, options) { if (typeof source !== 'object' && typeof source !== 'function') { if (isArray(target)) { - target.push(source); + var nextIndex = target.length; + if (options && typeof options.arrayLimit === 'number' && nextIndex > options.arrayLimit) { + return markOverflow(arrayToObject(target.concat(source), options), nextIndex); + } + target[nextIndex] = source; } else if (target && typeof target === 'object') { if (isOverflow(target)) { // Add at next numeric index for overflow objects @@ -107,7 +111,11 @@ var merge = function merge(target, source, options) { } return markOverflow(result, getMaxIndex(source) + 1); } - return [target].concat(source); + var combined = [target].concat(source); + if (options && typeof options.arrayLimit === 'number' && combined.length > options.arrayLimit) { + return markOverflow(arrayToObject(combined, options), combined.length - 1); + } + return combined; } var mergeTarget = target; @@ -122,7 +130,7 @@ var merge = function merge(target, source, options) { if (targetItem && typeof targetItem === 'object' && item && typeof item === 'object') { target[i] = merge(targetItem, item, options); } else { - target.push(item); + target[target.length] = item; } } else { target[i] = item; @@ -139,6 +147,17 @@ var merge = function merge(target, source, options) { } else { acc[key] = value; } + + if (isOverflow(source) && !isOverflow(acc)) { + markOverflow(acc, getMaxIndex(source)); + } + if (isOverflow(acc)) { + var keyNum = parseInt(key, 10); + if (String(keyNum) === key && keyNum >= 0 && keyNum > getMaxIndex(acc)) { + setMaxIndex(acc, keyNum); + } + } + return acc; }, mergeTarget); }; @@ -255,8 +274,8 @@ var compact = function compact(value) { var key = keys[j]; var val = obj[key]; if (typeof val === 'object' && val !== null && refs.indexOf(val) === -1) { - queue.push({ obj: obj, prop: key }); - refs.push(val); + queue[queue.length] = { obj: obj, prop: key }; + refs[refs.length] = val; } } } @@ -298,7 +317,7 @@ var maybeMap = function maybeMap(val, fn) { if (isArray(val)) { var mapped = []; for (var i = 0; i < val.length; i += 1) { - mapped.push(fn(val[i])); + mapped[mapped.length] = fn(val[i]); } return mapped; } @@ -315,6 +334,7 @@ module.exports = { isBuffer: isBuffer, isOverflow: isOverflow, isRegExp: isRegExp, + markOverflow: markOverflow, maybeMap: maybeMap, merge: merge }; diff --git a/package.json b/package.json index e4875e4a..cb5cfbe0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "qs", "description": "A querystring parser that supports nesting and arrays, with a depth limit", "homepage": "https://github.com/ljharb/qs", - "version": "6.14.1", + "version": "6.14.2", "repository": { "type": "git", "url": "https://github.com/ljharb/qs.git" diff --git a/test/parse.js b/test/parse.js index 7b353ffc..6234fefa 100644 --- a/test/parse.js +++ b/test/parse.js @@ -261,11 +261,11 @@ test('parse()', function (t) { }); t.test('limits specific array indices to arrayLimit', function (st) { - st.deepEqual(qs.parse('a[20]=a', { arrayLimit: 20 }), { a: ['a'] }); - st.deepEqual(qs.parse('a[21]=a', { arrayLimit: 20 }), { a: { 21: 'a' } }); + st.deepEqual(qs.parse('a[19]=a', { arrayLimit: 20 }), { a: ['a'] }); + st.deepEqual(qs.parse('a[20]=a', { arrayLimit: 20 }), { a: { 20: 'a' } }); - st.deepEqual(qs.parse('a[20]=a'), { a: ['a'] }); - st.deepEqual(qs.parse('a[21]=a'), { a: { 21: 'a' } }); + st.deepEqual(qs.parse('a[19]=a'), { a: ['a'] }); + st.deepEqual(qs.parse('a[20]=a'), { a: { 20: 'a' } }); st.end(); }); @@ -483,7 +483,7 @@ test('parse()', function (t) { t.test('allows overriding array limit', function (st) { st.deepEqual(qs.parse('a[0]=b', { arrayLimit: -1 }), { a: { 0: 'b' } }); - st.deepEqual(qs.parse('a[0]=b', { arrayLimit: 0 }), { a: ['b'] }); + st.deepEqual(qs.parse('a[0]=b', { arrayLimit: 0 }), { a: { 0: 'b' } }); st.deepEqual(qs.parse('a[-1]=b', { arrayLimit: -1 }), { a: { '-1': 'b' } }); st.deepEqual(qs.parse('a[-1]=b', { arrayLimit: 0 }), { a: { '-1': 'b' } }); @@ -1118,6 +1118,7 @@ test('parse()', function (t) { }); st.test('throws error when array limit exceeded', function (sst) { + // 4 elements exceeds limit of 3 sst['throws']( function () { qs.parse('a[]=1&a[]=2&a[]=3&a[]=4', { arrayLimit: 3, throwOnLimitExceeded: true }); @@ -1128,6 +1129,14 @@ test('parse()', function (t) { sst.end(); }); + st.test('does not throw when at limit', function (sst) { + // 3 elements = limit of 3, should not throw + var result = qs.parse('a[]=1&a[]=2&a[]=3', { arrayLimit: 3, throwOnLimitExceeded: true }); + sst.ok(Array.isArray(result.a), 'result is an array'); + sst.deepEqual(result.a, ['1', '2', '3'], 'all values present'); + sst.end(); + }); + st.test('converts array to object if length is greater than limit', function (sst) { var result = qs.parse('a[1]=1&a[2]=2&a[3]=3&a[4]=4&a[5]=5&a[6]=6', { arrayLimit: 5 }); @@ -1135,6 +1144,40 @@ test('parse()', function (t) { sst.end(); }); + st.test('throws error when indexed notation exceeds arrayLimit with throwOnLimitExceeded', function (sst) { + sst['throws']( + function () { + qs.parse('a[1001]=b', { arrayLimit: 1000, throwOnLimitExceeded: true }); + }, + new RangeError('Array limit exceeded. Only 1000 elements allowed in an array.'), + 'throws error for a single index exceeding arrayLimit' + ); + + sst['throws']( + function () { + qs.parse('a[0]=1&a[1]=2&a[2]=3&a[10]=4', { arrayLimit: 6, throwOnLimitExceeded: true, allowSparse: true }); + }, + new RangeError('Array limit exceeded. Only 6 elements allowed in an array.'), + 'throws error when a sparse index exceeds arrayLimit' + ); + + sst.end(); + }); + + st.test('does not throw for indexed notation within arrayLimit with throwOnLimitExceeded', function (sst) { + var result = qs.parse('a[4]=b', { arrayLimit: 5, throwOnLimitExceeded: true, allowSparse: true }); + sst.ok(Array.isArray(result.a), 'result is an array'); + sst.equal(result.a.length, 5, 'array has correct length'); + sst.equal(result.a[4], 'b', 'value at index 4 is correct'); + sst.end(); + }); + + st.test('silently converts to object for indexed notation exceeding arrayLimit without throwOnLimitExceeded', function (sst) { + var result = qs.parse('a[1001]=b', { arrayLimit: 1000 }); + sst.deepEqual(result, { a: { 1001: 'b' } }, 'converts to object without throwing'); + sst.end(); + }); + st.end(); }); @@ -1304,24 +1347,72 @@ test('DOS', function (t) { }); test('arrayLimit boundary conditions', function (t) { + // arrayLimit is the max number of elements allowed in an array t.test('exactly at the limit stays as array', function (st) { + // 3 elements = limit of 3 var result = qs.parse('a[]=1&a[]=2&a[]=3', { arrayLimit: 3 }); - st.ok(Array.isArray(result.a), 'result is an array when exactly at limit'); + st.ok(Array.isArray(result.a), 'result is an array when count equals limit'); st.deepEqual(result.a, ['1', '2', '3'], 'all values present'); st.end(); }); t.test('one over the limit converts to object', function (st) { + // 4 elements exceeds limit of 3 var result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4', { arrayLimit: 3 }); st.notOk(Array.isArray(result.a), 'result is not an array when over limit'); st.deepEqual(result.a, { 0: '1', 1: '2', 2: '3', 3: '4' }, 'all values preserved as object'); st.end(); }); - t.test('arrayLimit 1 with two values', function (st) { + t.test('arrayLimit 1 with one value', function (st) { + // 1 element = limit of 1 + var result = qs.parse('a[]=1', { arrayLimit: 1 }); + st.ok(Array.isArray(result.a), 'result is an array when count equals limit'); + st.deepEqual(result.a, ['1'], 'value preserved as array'); + st.end(); + }); + + t.test('arrayLimit 1 with two values converts to object', function (st) { + // 2 elements exceeds limit of 1 var result = qs.parse('a[]=1&a[]=2', { arrayLimit: 1 }); st.notOk(Array.isArray(result.a), 'result is not an array'); - st.deepEqual(result.a, { 0: '1', 1: '2' }, 'both values preserved'); + st.deepEqual(result.a, { 0: '1', 1: '2' }, 'all values preserved as object'); + st.end(); + }); + + t.end(); +}); + +test('comma + arrayLimit', function (t) { + t.test('comma-separated values within arrayLimit stay as array', function (st) { + var result = qs.parse('a=1,2,3', { comma: true, arrayLimit: 5 }); + st.ok(Array.isArray(result.a), 'result is an array'); + st.deepEqual(result.a, ['1', '2', '3'], 'all values present'); + st.end(); + }); + + t.test('comma-separated values exceeding arrayLimit convert to object', function (st) { + var result = qs.parse('a=1,2,3,4', { comma: true, arrayLimit: 3 }); + st.notOk(Array.isArray(result.a), 'result is not an array when over limit'); + st.deepEqual(result.a, { 0: '1', 1: '2', 2: '3', 3: '4' }, 'all values preserved as object'); + st.end(); + }); + + t.test('comma-separated values exceeding arrayLimit with throwOnLimitExceeded throws', function (st) { + st['throws']( + function () { + qs.parse('a=1,2,3,4', { comma: true, arrayLimit: 3, throwOnLimitExceeded: true }); + }, + new RangeError('Array limit exceeded. Only 3 elements allowed in an array.'), + 'throws error when comma-split exceeds array limit' + ); + st.end(); + }); + + t.test('comma-separated values at exactly arrayLimit stay as array', function (st) { + var result = qs.parse('a=1,2,3', { comma: true, arrayLimit: 3 }); + st.ok(Array.isArray(result.a), 'result is an array when exactly at limit'); + st.deepEqual(result.a, ['1', '2', '3'], 'all values present'); st.end(); }); @@ -1384,13 +1475,38 @@ test('mixed array and object notation', function (t) { }); t.test('multiple plain values exceeding limit', function (st) { + // 3 elements (indices 0-2), max index 2 > limit 1 st.deepEqual( - qs.parse('a=b&a=c&a=d', { arrayLimit: 2 }), + qs.parse('a=b&a=c&a=d', { arrayLimit: 1 }), { a: { 0: 'b', 1: 'c', 2: 'd' } }, 'duplicate plain keys convert to object when exceeding limit' ); st.end(); }); + t.test('mixed notation produces consistent results when arrayLimit is exceeded', function (st) { + var expected = { a: { 0: 'b', 1: 'c', 2: 'd' } }; + + st.deepEqual( + qs.parse('a[]=b&a[1]=c&a=d', { arrayLimit: -1 }), + expected, + 'arrayLimit -1' + ); + + st.deepEqual( + qs.parse('a[]=b&a[1]=c&a=d', { arrayLimit: 0 }), + expected, + 'arrayLimit 0' + ); + + st.deepEqual( + qs.parse('a[]=b&a[1]=c&a=d', { arrayLimit: 1 }), + expected, + 'arrayLimit 1' + ); + + st.end(); + }); + t.end(); }); diff --git a/test/utils.js b/test/utils.js index defb7f26..65baea72 100644 --- a/test/utils.js +++ b/test/utils.js @@ -69,12 +69,14 @@ test('merge()', function (t) { ); t.test('with overflow objects (from arrayLimit)', function (st) { + // arrayLimit is max index, so with limit 0, max index 0 is allowed (1 element) + // To create overflow, need 2+ elements with limit 0, or 3+ with limit 1, etc. st.test('merges primitive into overflow object at next index', function (s2t) { - // Create an overflow object via combine - var overflow = utils.combine(['a'], 'b', 1, false); + // Create an overflow object via combine: 3 elements (indices 0-2) with limit 0 + var overflow = utils.combine(['a', 'b'], 'c', 0, false); s2t.ok(utils.isOverflow(overflow), 'overflow object is marked'); - var merged = utils.merge(overflow, 'c'); - s2t.deepEqual(merged, { 0: 'a', 1: 'b', 2: 'c' }, 'adds primitive at next numeric index'); + var merged = utils.merge(overflow, 'd'); + s2t.deepEqual(merged, { 0: 'a', 1: 'b', 2: 'c', 3: 'd' }, 'adds primitive at next numeric index'); s2t.end(); }); @@ -94,21 +96,21 @@ test('merge()', function (t) { }); st.test('merges overflow object into primitive', function (s2t) { - // Create an overflow object via combine - var overflow = utils.combine([], 'b', 0, false); + // Create an overflow object via combine: 2 elements (indices 0-1) with limit 0 + var overflow = utils.combine(['a'], 'b', 0, false); s2t.ok(utils.isOverflow(overflow), 'overflow object is marked'); - var merged = utils.merge('a', overflow); + var merged = utils.merge('c', overflow); s2t.ok(utils.isOverflow(merged), 'result is also marked as overflow'); - s2t.deepEqual(merged, { 0: 'a', 1: 'b' }, 'creates object with primitive at 0, source values shifted'); + s2t.deepEqual(merged, { 0: 'c', 1: 'a', 2: 'b' }, 'creates object with primitive at 0, source values shifted'); s2t.end(); }); st.test('merges overflow object with multiple values into primitive', function (s2t) { - // Create an overflow object via combine - var overflow = utils.combine(['b'], 'c', 1, false); + // Create an overflow object via combine: 3 elements (indices 0-2) with limit 0 + var overflow = utils.combine(['b', 'c'], 'd', 0, false); s2t.ok(utils.isOverflow(overflow), 'overflow object is marked'); var merged = utils.merge('a', overflow); - s2t.deepEqual(merged, { 0: 'a', 1: 'b', 2: 'c' }, 'shifts all source indices by 1'); + s2t.deepEqual(merged, { 0: 'a', 1: 'b', 2: 'c', 3: 'd' }, 'shifts all source indices by 1'); s2t.end(); }); @@ -196,7 +198,7 @@ test('combine()', function (t) { st.test('exactly at the limit stays as array', function (s2t) { var combined = utils.combine(['a', 'b'], 'c', 3, false); - s2t.deepEqual(combined, ['a', 'b', 'c'], 'stays as array when exactly at limit'); + s2t.deepEqual(combined, ['a', 'b', 'c'], 'stays as array when count equals limit'); s2t.ok(Array.isArray(combined), 'result is an array'); s2t.end(); }); @@ -208,16 +210,30 @@ test('combine()', function (t) { s2t.end(); }); - st.test('with arrayLimit 0', function (s2t) { + st.test('with arrayLimit 1', function (s2t) { + var combined = utils.combine([], 'a', 1, false); + s2t.deepEqual(combined, ['a'], 'stays as array when count equals limit'); + s2t.ok(Array.isArray(combined), 'result is an array'); + s2t.end(); + }); + + st.test('with arrayLimit 0 converts single element to object', function (s2t) { var combined = utils.combine([], 'a', 0, false); - s2t.deepEqual(combined, { 0: 'a' }, 'converts single element to object with arrayLimit 0'); + s2t.deepEqual(combined, { 0: 'a' }, 'converts to object when count exceeds limit'); + s2t.notOk(Array.isArray(combined), 'result is not an array'); + s2t.end(); + }); + + st.test('with arrayLimit 0 and two elements converts to object', function (s2t) { + var combined = utils.combine(['a'], 'b', 0, false); + s2t.deepEqual(combined, { 0: 'a', 1: 'b' }, 'converts to object when count exceeds limit'); s2t.notOk(Array.isArray(combined), 'result is not an array'); s2t.end(); }); st.test('with plainObjects option', function (s2t) { - var combined = utils.combine(['a'], 'b', 1, true); - var expected = { __proto__: null, 0: 'a', 1: 'b' }; + var combined = utils.combine(['a', 'b'], 'c', 1, true); + var expected = { __proto__: null, 0: 'a', 1: 'b', 2: 'c' }; s2t.deepEqual(combined, expected, 'converts to object with null prototype'); s2t.equal(Object.getPrototypeOf(combined), null, 'result has null prototype when plainObjects is true'); s2t.end(); @@ -228,13 +244,13 @@ test('combine()', function (t) { t.test('with existing overflow object', function (st) { st.test('adds to existing overflow object at next index', function (s2t) { - // Create overflow object first via combine - var overflow = utils.combine(['a'], 'b', 1, false); + // Create overflow object first via combine: 3 elements (indices 0-2) with limit 0 + var overflow = utils.combine(['a', 'b'], 'c', 0, false); s2t.ok(utils.isOverflow(overflow), 'initial object is marked as overflow'); - var combined = utils.combine(overflow, 'c', 10, false); + var combined = utils.combine(overflow, 'd', 10, false); s2t.equal(combined, overflow, 'returns the same object (mutated)'); - s2t.deepEqual(combined, { 0: 'a', 1: 'b', 2: 'c' }, 'adds value at next numeric index'); + s2t.deepEqual(combined, { 0: 'a', 1: 'b', 2: 'c', 3: 'd' }, 'adds value at next numeric index'); s2t.end(); });