diff --git a/package-lock.json b/package-lock.json index ecf8f56..9260811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "packageurl-js", - "version": "1.0.1", + "version": "1.2.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "packageurl-js", - "version": "1.0.1", + "version": "1.2.1", "license": "MIT", "devDependencies": { "mocha": "^10.2.0" diff --git a/src/package-url.d.ts b/src/package-url.d.ts index 8c5dc2e..63b809a 100644 --- a/src/package-url.d.ts +++ b/src/package-url.d.ts @@ -50,7 +50,7 @@ declare module "packageurl-js" { /** * some name prefix such as a Maven groupid, a Docker image owner, a GitHub user or organization. Optional and type-specific. */ - namespace: string | undefined | null; + namespace: string | undefined; /** * the name of the package. Required. @@ -60,19 +60,19 @@ declare module "packageurl-js" { /** * the version of the package. Optional. */ - version: string | undefined | null; + version: string | undefined; /** * extra qualifying data for a package such as an OS, architecture, a distro, etc. Optional and type-specific. */ qualifiers: { [key: string]: string; - } | undefined | null; + } | undefined; /** * extra subpath within a package, relative to the package root. Optional. */ - subpath: string | undefined | null; + subpath: string | undefined; constructor(type: string, namespace: string | undefined | null, diff --git a/src/package-url.js b/src/package-url.js index 04e2f0b..e23fc23 100644 --- a/src/package-url.js +++ b/src/package-url.js @@ -19,6 +19,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +'use strict'; const KnownQualifierNames = Object.freeze({ // known qualifiers as defined here: @@ -30,6 +31,47 @@ const KnownQualifierNames = Object.freeze({ Checksum: 'checksum' }); +function encodeWithColon(str) { + return encodeURIComponent(str).replace(/%3A/g, ':'); +} + +function encodeWithColonAndForwardSlash(str) { + return encodeURIComponent(str).replace(/%3A/g, ':').replace(/%2F/g, '/'); +} + +function encodeWithColonAndPlusSign(str) { + return encodeURIComponent(str).replace(/%3A/g, ':').replace(/%2B/g,'+'); +} + +function encodeWithForwardSlash(str) { + return encodeURIComponent(str).replace(/%2F/g, '/'); +} + +function valifateQualifiers(qualifiers) { + if (typeof qualifiers !== 'object' || qualifiers === null) { + throw new Error('Invalid purl: "qualifiers" argument must be a dictionary.'); + } + const qualifiersKeys = Object.keys(qualifiers); + for (let i = 0, { length } = qualifiersKeys; i < length; i += 1) { + const key = qualifiersKeys[i]; + if (!/^[a-z]+$/i.test(key) && !/[\.-_]/.test(key)) { + throw new Error(`Invalid purl: qualifier "${key}" contains an illegal character.`); + } + } +} + +function validateRequired(name, value) { + if (!value) { + throw new Error(`Invalid purl: "${name}" is a required field.`); + } +} + +function validateStrings(name, value) { + if (typeof value === 'string' ? value.length === 0 : value) { + throw new Error(`Invalid purl: "'${name}" argument must be a non-empty string.`); + } +} + class PackageURL { static get KnownQualifierNames() { @@ -37,139 +79,114 @@ class PackageURL { } constructor(type, namespace, name, version, qualifiers, subpath) { - let required = { 'type': type, 'name': name }; - Object.keys(required).forEach(key => { - if (!required[key]) { - throw new Error('Invalid purl: "' + key + '" is a required field.'); - } - }); + validateRequired('type', type); + validateRequired('name', name); - let strings = { 'type': type, 'namespace': namespace, 'name': name, 'versions': version, 'subpath': subpath }; - Object.keys(strings).forEach(key => { - if (strings[key] && typeof strings[key] === 'string' || !strings[key]) { - return; - } - throw new Error('Invalid purl: "' + key + '" argument must be a string.'); - }); + validateStrings('type', type); + validateStrings('namespace', namespace); + validateStrings('name', name); + validateStrings('version', version); + validateStrings('subpath', subpath); if (qualifiers) { - if (typeof qualifiers !== 'object') { - throw new Error('Invalid purl: "qualifiers" argument must be a dictionary.'); - } - Object.keys(qualifiers).forEach(key => { - if (!/^[a-z]+$/i.test(key) && !/[\.-_]/.test(key)) { - throw new Error('Invalid purl: qualifier "' + key + '" contains an illegal character.'); - } - }); + valifateQualifiers(qualifiers); } this.type = type; this.name = name; - this.namespace = namespace; - this.version = version; - this.qualifiers = qualifiers; - this.subpath = subpath; + this.namespace = namespace ?? undefined; + this.version = version ?? undefined; + this.qualifiers = qualifiers ?? undefined; + this.subpath = subpath ?? undefined; } _handlePyPi() { - this.name = this.name.toLowerCase().replace(/_/g, '-'); + this.name = this.name.toLowerCase().replaceAll('_', '-'); } _handlePub() { - this.name = this.name.toLowerCase(); - if (!/^[a-z0-9_]+$/i.test(this.name)) { + const lowered = this.name.toLowerCase(); + if (!/^\w+$/.test(lowered)) { throw new Error('Invalid purl: contains an illegal character.'); } + this.name = lowered; } toString() { - var purl = ['pkg:', encodeURIComponent(this.type), '/']; - - if (this.type === 'pypi') { + const { type } = this; + if (type === 'pypi') { this._handlePyPi(); - } - if (this.type === 'pub') { + } else if (type === 'pub') { this._handlePub(); } + const { namespace, name, version, qualifiers, subpath } = this; + let purl = `pkg:${encodeURIComponent(type)}/`; - if (this.namespace) { - purl.push( - encodeURIComponent(this.namespace) - .replace(/%3A/g, ':') - .replace(/%2F/g, '/') - ); - purl.push('/'); + if (namespace) { + purl = `${purl}${encodeWithColonAndForwardSlash(namespace)}/`; } - purl.push(encodeURIComponent(this.name).replace(/%3A/g, ':')); + purl = `${purl}${encodeWithColon(name)}` - if (this.version) { - purl.push('@'); - purl.push(encodeURIComponent(this.version).replace(/%3A/g, ':').replace(/%2B/g,'+')); + if (version) { + purl = `${purl}@${encodeWithColonAndPlusSign(version)}`; } - if (this.qualifiers) { - purl.push('?'); - - let qualifiers = this.qualifiers; - let qualifierString = []; - Object.keys(qualifiers).sort().forEach(key => { - qualifierString.push( - encodeURIComponent(key).replace(/%3A/g, ':') - + '=' - + encodeURIComponent(qualifiers[key]).replace(/%2F/g, '/') - ); - }); - - purl.push(qualifierString.join('&')); + if (qualifiers) { + let qstr = ''; + const qualifiersKeys = Object.keys(qualifiers).sort(); + for (let i = 0, { length } = qualifiersKeys; i < length; i += 1) { + const key = qualifiersKeys[i]; + qstr = `${qstr}${qstr.length ? '&' : ''}${encodeWithColon(key)}=${encodeWithForwardSlash(qualifiers[key])}` + } + purl = `${purl}?${qstr}`; } - if (this.subpath) { - purl.push('#'); - purl.push(encodeURIComponent(this.subpath) - .replace(/%3A/g, ':') - .replace(/%2F/g, '/')); + if (subpath) { + purl = `${purl}#${encodeWithColonAndForwardSlash(subpath)}` } - return purl.join(''); + return purl } static fromString(purl) { - if (!purl || typeof purl !== 'string' || !purl.trim()) { - throw new Error('A purl string argument is required.'); + if (typeof purl !== 'string' || purl.length === 0 || purl.trim().length === 0) { + throw new Error('A purl non-empty string argument is required.'); } - - let scheme = purl.slice(0, purl.indexOf(':')) - let remainder = purl.slice(purl.indexOf(':') + 1) + const scheme = purl.slice(0, purl.indexOf(':')); if (scheme !== 'pkg') { throw new Error('purl is missing the required "pkg" scheme component.'); } + + let remainder = purl.slice(purl.indexOf(':') + 1); // this strip '/, // and /// as possible in :// or :/// // from https://gist.github.com/refo/47632c8a547f2d9b6517#file-remove-leading-slash remainder = remainder.trim().replace(/^\/+/g, ''); let type - [type, remainder] = remainder.split('/', 2); + ({ 0: type, 1: remainder } = remainder.split('/', 2)); if (!type || !remainder) { throw new Error('purl is missing the required "type" component.'); } - type = decodeURIComponent(type) + type = decodeURIComponent(type); - let url = new URL(purl); + const url = new URL(purl); - let qualifiers = null; - url.searchParams.forEach((value, key) => { - if (!qualifiers) { - qualifiers = {}; + const { searchParams } = url; + let qualifiers = undefined; + if (searchParams.size) { + qualifiers = {}; + for (const { 0: key, 1: value } of searchParams) { + qualifiers[key] = value; } - qualifiers[key] = value; - }); - let subpath = url.hash; + } + + let { hash: subpath } = url; if (subpath.indexOf('#') === 0) { - subpath = subpath.substring(1); + subpath = subpath.slice(1); } subpath = subpath.length === 0 - ? null - : decodeURIComponent(subpath) + ? undefined + : decodeURIComponent(subpath); if (url.username !== '' || url.password !== '') { throw new Error('Invalid purl: cannot contain a "user:pass@host:port"'); @@ -177,36 +194,36 @@ class PackageURL { // this strip '/, // and /// as possible in :// or :/// // from https://gist.github.com/refo/47632c8a547f2d9b6517#file-remove-leading-slash - let path = url.pathname.trim().replace(/^\/+/g, ''); + const path = url.pathname.trim().replace(/^\/+/g, ''); // version is optional - check for existence - let version = null; - if (path.includes('@')) { - let index = path.indexOf('@'); - let rawVersion= path.substring(index + 1); + let version = undefined; + const atSignIndex = path.indexOf('@'); + if (atSignIndex !== -1) { + const rawVersion = path.slice(atSignIndex + 1); version = decodeURIComponent(rawVersion); // Convert percent-encoded colons (:) back, to stay in line with the `toString` // implementation of this library. // https://github.com/package-url/packageurl-js/blob/58026c86978c6e356e5e07f29ecfdccbf8829918/src/package-url.js#L98C10-L98C10 - let versionEncoded = encodeURIComponent(version).replace(/%3A/g, ':').replace(/%2B/g,'+'); + const versionEncoded = encodeWithColonAndPlusSign(version); if (rawVersion !== versionEncoded) { throw new Error('Invalid purl: version must be percent-encoded'); } - remainder = path.substring(0, index); + remainder = path.slice(0, atSignIndex); } else { remainder = path; } // The 'remainder' should now consist of an optional namespace and the name - let remaining = remainder.split('/').slice(1); - let name = null; - let namespace = null; + const remaining = remainder.split('/').slice(1); + let name = ''; + let namespace = undefined; if (remaining.length > 1) { - let nameIndex = remaining.length - 1; - let namespaceComponents = remaining.slice(0, nameIndex); + const nameIndex = remaining.length - 1; + const namespaceComponents = remaining.slice(0, nameIndex); name = decodeURIComponent(remaining[nameIndex]); namespace = decodeURIComponent(namespaceComponents.join('/')); } else if (remaining.length === 1) { diff --git a/test/benchmark.spec.js b/test/benchmark.spec.js new file mode 100644 index 0000000..14cd121 --- /dev/null +++ b/test/benchmark.spec.js @@ -0,0 +1,36 @@ +const assert = require('assert'); + +const TEST_FILE = require('./data/test-suite-data.json'); + +/** @type {import('../src/package-url')} */ +const PackageURL = require('../src/package-url'); + +describe('PackageURL', () => { + it('Benchmarking the library', () => { + const iterations = 10000; + const data = TEST_FILE.filter(obj => !obj.is_invalid); + const { length: dataLength } = data; + const objects = [] + for (let i = 0; i < iterations; i += dataLength) { + const delta = iterations - (i + dataLength); + if (delta < 0) { + objects.push(...data.slice(0, delta)); + } else { + objects.push(...data); + } + } + const start = Date.now(); + for (let i = 0; i < iterations; i += 1) { + const obj = objects[i]; + const purl = new PackageURL(obj.type, obj.namespace, obj.name, obj.version, obj.qualifiers, obj.subpath); + PackageURL.fromString(purl.toString()) + } + const end = Date.now(); + console.log( + `avg exec time of ${iterations} iterations (in ms): ${ + (end - start) / iterations + }` + ); + assert.ok((end - start) > 0); + }); +}); diff --git a/test/package-url.spec.js b/test/package-url.spec.js index 8d93837..79986cf 100644 --- a/test/package-url.spec.js +++ b/test/package-url.spec.js @@ -21,7 +21,7 @@ SOFTWARE. */ const assert = require('assert'); -const {describe, it} = require("mocha"); +const { describe, it } = require("mocha"); const TEST_FILE = require('./data/test-suite-data.json'); @@ -32,18 +32,18 @@ describe('PackageURL', function () { describe('toString()', function () { it('all components encode #', function () { /* The # is a delimiter between url and subpath. */ - var purl = new PackageURL('ty#pe', 'name#space', 'na#me', 'ver#sion', {'foo':'bar#baz'}, 'sub#path'); + const purl = new PackageURL('ty#pe', 'name#space', 'na#me', 'ver#sion', {'foo':'bar#baz'}, 'sub#path'); assert.strictEqual(purl.toString(), 'pkg:ty%23pe/name%23space/na%23me@ver%23sion?foo=bar%23baz#sub%23path') }) it('all components encode @', function () { /* The @ is a delimiter between package name and version. */ - var purl = new PackageURL('ty@pe', 'name@space', 'na@me', 'ver@sion', {'foo':'bar@baz'}, 'sub@path'); + const purl = new PackageURL('ty@pe', 'name@space', 'na@me', 'ver@sion', {'foo':'bar@baz'}, 'sub@path'); assert.strictEqual(purl.toString(), 'pkg:ty%40pe/name%40space/na%40me@ver%40sion?foo=bar%40baz#sub%40path') }) it('path components encode /', function () { /* only namespace is allowed to have multiple segments separated by `/`` */ - var purl = new PackageURL('ty/pe', 'namespace1/namespace2', 'na/me'); + const purl = new PackageURL('ty/pe', 'namespace1/namespace2', 'na/me'); assert.strictEqual(purl.toString(), 'pkg:ty%2Fpe/namespace1/namespace2/na%2Fme') }) }) @@ -55,7 +55,7 @@ describe('PackageURL', function () { const purl = PackageURL.fromString(purlString) assert.strictEqual(purl.type, 'npm') - assert.strictEqual(purl.namespace, null) + assert.strictEqual(purl.namespace, undefined) assert.strictEqual(purl.name, 'packageurl-js') assert.strictEqual(purl.version, '0.0.7') assert.deepStrictEqual(purl.qualifiers, { @@ -69,7 +69,7 @@ describe('PackageURL', function () { const purl = PackageURL.fromString(purlString) assert.strictEqual(purl.type, 'npm') - assert.strictEqual(purl.namespace, null) + assert.strictEqual(purl.namespace, undefined) assert.strictEqual(purl.name, 'packageurl-js') assert.strictEqual(purl.version, '0.0.7') assert.deepStrictEqual(purl.qualifiers, { @@ -78,14 +78,14 @@ describe('PackageURL', function () { }); it('namespace with multiple segments', function () { - var purl = PackageURL.fromString('pkg:ty%2Fpe/namespace1/namespace2/na%2Fme') + const purl = PackageURL.fromString('pkg:ty%2Fpe/namespace1/namespace2/na%2Fme') assert.strictEqual('ty/pe', purl.type) assert.strictEqual('namespace1/namespace2', purl.namespace) assert.strictEqual('na/me', purl.name) }) it('encoded #', function () { - var purl = PackageURL.fromString('pkg:ty%23pe/name%23space/na%23me@ver%23sion?foo=bar%23baz#sub%23path') + const purl = PackageURL.fromString('pkg:ty%23pe/name%23space/na%23me@ver%23sion?foo=bar%23baz#sub%23path') assert.strictEqual('ty#pe', purl.type) assert.strictEqual('name#space', purl.namespace) assert.strictEqual('na#me', purl.name) @@ -95,7 +95,7 @@ describe('PackageURL', function () { }) it('encoded @', function () { - var purl = PackageURL.fromString('pkg:ty%40pe/name%40space/na%40me@ver%40sion?foo=bar%40baz#sub%40path') + const purl = PackageURL.fromString('pkg:ty%40pe/name%40space/na%40me@ver%40sion?foo=bar%40baz#sub%40path') assert.strictEqual('ty@pe', purl.type) assert.strictEqual('name@space', purl.namespace) assert.strictEqual('na@me', purl.name) @@ -110,7 +110,7 @@ describe('PackageURL', function () { if (obj.is_invalid) { it('should not be possible to create invalid PackageURLs', function () { try { - var purl = new PackageURL(obj.type, obj.namespace, obj.name, obj.version, obj.qualifiers, obj.subpath); + const purl = new PackageURL(obj.type, obj.namespace, obj.name, obj.version, obj.qualifiers, obj.subpath); assert.fail(); } catch (e) { assert.ok(e.toString().includes('is a required field') || e.toString().includes('Invalid purl')); @@ -125,27 +125,27 @@ describe('PackageURL', function () { }); } else { it('should be able to create valid PackageURLs', function () { - var purl = new PackageURL(obj.type, obj.namespace, obj.name, obj.version, obj.qualifiers, obj.subpath); + const purl = new PackageURL(obj.type, obj.namespace, obj.name, obj.version, obj.qualifiers, obj.subpath); assert.strictEqual(purl.type, obj.type); assert.strictEqual(purl.name, obj.name); - assert.strictEqual(purl.namespace, obj.namespace); - assert.strictEqual(purl.version, obj.version); - assert.deepStrictEqual(purl.qualifiers, obj.qualifiers); - assert.strictEqual(purl.subpath, obj.subpath, ); + assert.strictEqual(purl.namespace, obj.namespace ?? undefined); + assert.strictEqual(purl.version, obj.version ?? undefined); + assert.deepStrictEqual(purl.qualifiers, obj.qualifiers ?? undefined); + assert.strictEqual(purl.subpath, obj.subpath ?? undefined); }); it('should be able to convert valid PackageURLs to a string', function () { - var purl = new PackageURL(obj.type, obj.namespace, obj.name, obj.version, obj.qualifiers, obj.subpath); + const purl = new PackageURL(obj.type, obj.namespace, obj.name, obj.version, obj.qualifiers, obj.subpath); assert.strictEqual(purl.toString(), obj.canonical_purl); }); it('should be able to parse valid PackageURLs', function () { - var purl = PackageURL.fromString(obj.canonical_purl); + const purl = PackageURL.fromString(obj.canonical_purl); assert.strictEqual(purl.toString(), obj.canonical_purl); assert.strictEqual(purl.type, obj.type); assert.strictEqual(purl.name, obj.name); - assert.strictEqual(purl.namespace, obj.namespace); - assert.strictEqual(purl.version, obj.version); - assert.deepStrictEqual(purl.qualifiers, obj.qualifiers); - assert.strictEqual(purl.subpath, obj.subpath); + assert.strictEqual(purl.namespace, obj.namespace ?? undefined); + assert.strictEqual(purl.version, obj.version ?? undefined); + assert.deepStrictEqual(purl.qualifiers, obj.qualifiers ?? undefined); + assert.strictEqual(purl.subpath, obj.subpath ?? undefined); }); it('should handle pypi package-urls per the purl-spec', function () { const purlMixedCasing = PackageURL.fromString('pkg:pypi/PYYaml@5.3.0');