From 2a5ad384fe4424eee0057671563ec60eed513e90 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 13 Aug 2024 10:00:50 -0400 Subject: [PATCH 01/15] chore: split out code into more modules --- src/constants.js | 7 + src/encode.js | 64 ++++++ src/lang.js | 13 ++ src/normalize.js | 125 +++++++++++ src/objects.js | 60 +++++ src/package-url.js | 540 ++++----------------------------------------- src/strings.js | 119 ++++++++++ src/validate.js | 189 ++++++++++++++++ 8 files changed, 623 insertions(+), 494 deletions(-) create mode 100644 src/constants.js create mode 100644 src/encode.js create mode 100644 src/lang.js create mode 100644 src/normalize.js create mode 100644 src/objects.js create mode 100644 src/strings.js create mode 100644 src/validate.js diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..05a10a4 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,7 @@ +'use strict' + +const LOOP_SENTINEL = 1_000_000 + +module.exports = { + LOOP_SENTINEL +} diff --git a/src/encode.js b/src/encode.js new file mode 100644 index 0000000..68a9995 --- /dev/null +++ b/src/encode.js @@ -0,0 +1,64 @@ +'use strict' + +const { encodeURIComponent } = globalThis + +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 encodeNamespace(namespace) { + return typeof namespace === 'string' && namespace.length + ? encodeWithColonAndForwardSlash(namespace) + : '' +} + +function encodeVersion(version) { + return typeof version === 'string' && version.length + ? encodeWithColonAndPlusSign(version) + : '' +} + +function encodeQualifiers(qualifiers) { + let query = '' + if (qualifiers !== null && typeof qualifiers === 'object') { + // Sort this list of qualifier strings lexicographically. + const qualifiersKeys = Object.keys(qualifiers).sort() + for (let i = 0, { length } = qualifiersKeys; i < length; i += 1) { + const key = qualifiersKeys[i] + query = `${query}${i === 0 ? '' : '&'}${key}=${encodeQualifierValue(qualifiers[key])}` + } + } + return query +} + +function encodeQualifierValue(qualifierValue) { + return typeof qualifierValue === 'string' && qualifierValue.length + ? encodeWithColonAndForwardSlash(qualifierValue) + : '' +} + +function encodeSubpath(subpath) { + return typeof subpath === 'string' && subpath.length + ? encodeWithForwardSlash(subpath) + : '' +} + +module.exports = { + encodeWithColonAndForwardSlash, + encodeWithColonAndPlusSign, + encodeWithForwardSlash, + encodeNamespace, + encodeVersion, + encodeQualifiers, + encodeQualifierValue, + encodeSubpath, + encodeURIComponent +} diff --git a/src/lang.js b/src/lang.js new file mode 100644 index 0000000..55531ce --- /dev/null +++ b/src/lang.js @@ -0,0 +1,13 @@ +'use strict' + +function isNullishOrEmptyString(value) { + return ( + value === null || + value === undefined || + (typeof value === 'string' && value.length === 0) + ) +} + +module.exports = { + isNullishOrEmptyString +} diff --git a/src/normalize.js b/src/normalize.js new file mode 100644 index 0000000..a1a8bea --- /dev/null +++ b/src/normalize.js @@ -0,0 +1,125 @@ +'use strict' + +const { isBlank } = require('./strings') + +const { decodeURIComponent } = globalThis + +function normalizeName(rawName) { + return typeof rawName === 'string' + ? decodeURIComponent(rawName).trim() + : undefined +} + +function normalizeNamespace(rawNamespace) { + return typeof rawNamespace === 'string' + ? normalizePath(decodeURIComponent(rawNamespace)) + : undefined +} + +function normalizePath(pathname, callback) { + let collapsed = '' + let start = 0 + // Leading and trailing slashes, i.e. '/', are not significant and should be + // stripped in the canonical form. + while (pathname.charCodeAt(start) === 47 /*'/'*/) { + start += 1 + } + let nextIndex = pathname.indexOf('/', start) + if (nextIndex === -1) { + return pathname.slice(start) + } + // Discard any empty string segments by collapsing repeated segment + // separator slashes, i.e. '/'. + while (nextIndex !== -1) { + const segment = pathname.slice(start, nextIndex) + if (callback === undefined || callback(segment)) { + collapsed = + collapsed + (collapsed.length === 0 ? '' : '/') + segment + } + start = nextIndex + 1 + while (pathname.charCodeAt(start) === 47) { + start += 1 + } + nextIndex = pathname.indexOf('/', start) + } + const lastSegment = pathname.slice(start) + if ( + lastSegment.length !== 0 && + (callback === undefined || callback(lastSegment)) + ) { + collapsed = collapsed + '/' + lastSegment + } + return collapsed +} + +function normalizeQualifiers(rawQualifiers) { + if ( + rawQualifiers === null || + rawQualifiers === undefined || + typeof rawQualifiers !== 'object' + ) { + return undefined + } + const qualifiers = { __proto__: null } + const entriesIterator = + // URL searchParams have an "entries" method that returns an iterator. + typeof rawQualifiers.entries === 'function' + ? rawQualifiers.entries() + : Object.entries(rawQualifiers) + for (const { 0: key, 1: value } of entriesIterator) { + const strValue = typeof value === 'string' ? value : String(value) + const trimmed = strValue.trim() + // Value cannot be an empty string: a key=value pair with an empty value + // is the same as no key/value at all for this key. + if (trimmed.length === 0) continue + // A key is case insensitive. The canonical form is lowercase. + qualifiers[key.toLowerCase()] = trimmed + } + return qualifiers +} + +function normalizeSubpath(rawSubpath) { + return typeof rawSubpath === 'string' + ? normalizePath(decodeURIComponent(rawSubpath), subpathFilter) + : undefined +} + +function normalizeType(rawType) { + // The type must NOT be percent-encoded. + // The type is case insensitive. The canonical form is lowercase. + return typeof rawType === 'string' + ? decodeURIComponent(rawType).trim().toLowerCase() + : undefined +} + +function normalizeVersion(rawVersion) { + return typeof rawVersion === 'string' + ? decodeURIComponent(rawVersion).trim() + : undefined +} + +function subpathFilter(segment) { + // When percent-decoded, a segment + // - must not be any of '.' or '..' + // - must not be empty + const { length } = segment + if (length === 1 && segment.charCodeAt(0) === 46 /*'.'*/) return false + if ( + length === 2 && + segment.charCodeAt(0) === 46 && + segment.charCodeAt(1) === 46 + ) { + return false + } + return !isBlank(segment) +} + +module.exports = { + normalizeName, + normalizeNamespace, + normalizePath, + normalizeQualifiers, + normalizeSubpath, + normalizeType, + normalizeVersion +} diff --git a/src/objects.js b/src/objects.js new file mode 100644 index 0000000..4ec671f --- /dev/null +++ b/src/objects.js @@ -0,0 +1,60 @@ +'use strict' + +const { LOOP_SENTINEL } = require('./constants') + +function isObject(value) { + return value !== null && typeof value === 'object' +} + +function recursiveFreeze(value_) { + if ( + value_ === null || + !(typeof value_ === 'object' || typeof value_ === 'function') || + Object.isFrozen(value_) + ) { + return value_ + } + const queue = [value_] + let { length: queueLength } = queue + let pos = 0 + while (pos < queueLength) { + if (pos === LOOP_SENTINEL) { + throw new Error( + 'Detected infinite loop in object crawl of recursiveFreeze' + ) + } + const obj = queue[pos++] + Object.freeze(obj) + if (Array.isArray(obj)) { + for (let i = 0, { length } = obj; i < length; i += 1) { + const item = obj[i] + if ( + item !== null && + (typeof item === 'object' || typeof item === 'function') && + !Object.isFrozen(item) + ) { + queue[queueLength++] = item + } + } + } else { + const keys = Reflect.ownKeys(obj) + for (let i = 0, { length } = keys; i < length; i += 1) { + const propValue = obj[keys[i]] + if ( + propValue !== null && + (typeof propValue === 'object' || + typeof propValue === 'function') && + !Object.isFrozen(propValue) + ) { + queue[queueLength++] = propValue + } + } + } + } + return value_ +} + +module.exports = { + isObject, + recursiveFreeze +} diff --git a/src/package-url.js b/src/package-url.js index e9df444..1887c93 100644 --- a/src/package-url.js +++ b/src/package-url.js @@ -21,12 +21,51 @@ SOFTWARE. */ 'use strict' -const LOOP_SENTINEL = 1_000_000 - -// This regexp is valid as of 2024-08-01. -// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string -const regexSemverNumberedGroups = - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ +const { + encodeNamespace, + encodeVersion, + encodeQualifiers, + encodeQualifierValue, + encodeSubpath, + encodeURIComponent +} = require('./encode') + +const { isNullishOrEmptyString } = require('./lang') + +const { + normalizeName, + normalizeNamespace, + normalizeQualifiers, + normalizeSubpath, + normalizeType, + normalizeVersion +} = require('./normalize') + +const { isObject, recursiveFreeze } = require('./objects') + +const { + isBlank, + isNonEmptyString, + isSemverString, + lowerName, + lowerNamespace, + lowerVersion, + replaceDashesWithUnderscores, + replaceUnderscoresWithDashes, + trimLeadingSlashes +} = require('./strings') + +const { + validateEmptyByType, + validateName, + validateNamespace, + validateQualifiers, + validateQualifierKey, + validateRequiredByType, + validateSubpath, + validateType, + validateVersion +} = require('./validate') const PurlComponentEncoder = (comp) => typeof comp === 'string' && comp.length ? encodeURIComponent(comp) : '' @@ -251,7 +290,7 @@ const Type = createHelpersNamespaceObject( if ( length && version.charCodeAt(0) === 118 /*'v'*/ && - !regexSemverNumberedGroups.test(version.slice(1)) + !isSemverString(version.slice(1)) ) { if (throws) { throw new Error( @@ -365,493 +404,6 @@ function createHelpersNamespaceObject(helpers, defaults = {}) { return nsObject } -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 encodeNamespace(namespace) { - return typeof namespace === 'string' && namespace.length - ? encodeWithColonAndForwardSlash(namespace) - : '' -} - -function encodeVersion(version) { - return typeof version === 'string' && version.length - ? encodeWithColonAndPlusSign(version) - : '' -} - -function encodeQualifiers(qualifiers) { - let query = '' - if (qualifiers !== null && typeof qualifiers === 'object') { - // Sort this list of qualifier strings lexicographically. - const qualifiersKeys = Object.keys(qualifiers).sort() - for (let i = 0, { length } = qualifiersKeys; i < length; i += 1) { - const key = qualifiersKeys[i] - query = `${query}${i === 0 ? '' : '&'}${key}=${encodeQualifierValue(qualifiers[key])}` - } - } - return query -} - -function encodeQualifierValue(qualifierValue) { - return typeof qualifierValue === 'string' && qualifierValue.length - ? encodeWithColonAndForwardSlash(qualifierValue) - : '' -} - -function encodeSubpath(subpath) { - return typeof subpath === 'string' && subpath.length - ? encodeWithForwardSlash(subpath) - : '' -} - -function isBlank(str) { - for (let i = 0, { length } = str; i < length; i += 1) { - const code = str.charCodeAt(i) - // prettier-ignore - if ( - !( - // Whitespace characters according to ECMAScript spec: - // https://tc39.es/ecma262/#sec-white-space - ( - code === 0x0020 || // Space - code === 0x0009 || // Tab - code === 0x000a || // Line Feed - code === 0x000b || // Vertical Tab - code === 0x000c || // Form Feed - code === 0x000d || // Carriage Return - code === 0x00a0 || // No-Break Space - code === 0x1680 || // Ogham Space Mark - code === 0x2000 || // En Quad - code === 0x2001 || // Em Quad - code === 0x2002 || // En Space - code === 0x2003 || // Em Space - code === 0x2004 || // Three-Per-Em Space - code === 0x2005 || // Four-Per-Em Space - code === 0x2006 || // Six-Per-Em Space - code === 0x2007 || // Figure Space - code === 0x2008 || // Punctuation Space - code === 0x2009 || // Thin Space - code === 0x200a || // Hair Space - code === 0x2028 || // Line Separator - code === 0x2029 || // Paragraph Separator - code === 0x202f || // Narrow No-Break Space - code === 0x205f || // Medium Mathematical Space - code === 0x3000 || // Ideographic Space - code === 0xfeff // Byte Order Mark - ) - ) - ) { - return false - } - } - return true -} - -function isNonEmptyString(value) { - return typeof value === 'string' && value.length > 0 -} - -function isNullishOrEmptyString(value) { - return ( - value === null || - value === undefined || - (typeof value === 'string' && value.length === 0) - ) -} - -function isObject(value) { - return value !== null && typeof value === 'object' -} - -function lowerName(purl) { - purl.name = purl.name.toLowerCase() -} - -function lowerNamespace(purl) { - const { namespace } = purl - if (typeof namespace === 'string') { - purl.namespace = namespace.toLowerCase() - } -} - -function lowerVersion(purl) { - const { version } = purl - if (typeof version === 'string') { - purl.version = version.toLowerCase() - } -} - -function normalizeName(rawName) { - return typeof rawName === 'string' - ? decodeURIComponent(rawName).trim() - : undefined -} - -function normalizeNamespace(rawNamespace) { - return typeof rawNamespace === 'string' - ? normalizePath(decodeURIComponent(rawNamespace)) - : undefined -} - -function normalizePath(pathname, callback) { - let collapsed = '' - let start = 0 - // Leading and trailing slashes, i.e. '/', are not significant and should be - // stripped in the canonical form. - while (pathname.charCodeAt(start) === 47 /*'/'*/) { - start += 1 - } - let nextIndex = pathname.indexOf('/', start) - if (nextIndex === -1) { - return pathname.slice(start) - } - // Discard any empty string segments by collapsing repeated segment - // separator slashes, i.e. '/'. - while (nextIndex !== -1) { - const segment = pathname.slice(start, nextIndex) - if (callback === undefined || callback(segment)) { - collapsed = - collapsed + (collapsed.length === 0 ? '' : '/') + segment - } - start = nextIndex + 1 - while (pathname.charCodeAt(start) === 47) { - start += 1 - } - nextIndex = pathname.indexOf('/', start) - } - const lastSegment = pathname.slice(start) - if ( - lastSegment.length !== 0 && - (callback === undefined || callback(lastSegment)) - ) { - collapsed = collapsed + '/' + lastSegment - } - return collapsed -} - -function normalizeQualifiers(rawQualifiers) { - if ( - rawQualifiers === null || - rawQualifiers === undefined || - typeof rawQualifiers !== 'object' - ) { - return undefined - } - const qualifiers = { __proto__: null } - const entriesIterator = - // URL searchParams have an "entries" method that returns an iterator. - typeof rawQualifiers.entries === 'function' - ? rawQualifiers.entries() - : Object.entries(rawQualifiers) - for (const { 0: key, 1: value } of entriesIterator) { - const strValue = typeof value === 'string' ? value : String(value) - const trimmed = strValue.trim() - // Value cannot be an empty string: a key=value pair with an empty value - // is the same as no key/value at all for this key. - if (trimmed.length === 0) continue - // A key is case insensitive. The canonical form is lowercase. - qualifiers[key.toLowerCase()] = trimmed - } - return qualifiers -} - -function normalizeSubpath(rawSubpath) { - return typeof rawSubpath === 'string' - ? normalizePath(decodeURIComponent(rawSubpath), subpathFilter) - : undefined -} - -function normalizeType(rawType) { - // The type must NOT be percent-encoded. - // The type is case insensitive. The canonical form is lowercase. - return typeof rawType === 'string' - ? decodeURIComponent(rawType).trim().toLowerCase() - : undefined -} - -function normalizeVersion(rawVersion) { - return typeof rawVersion === 'string' - ? decodeURIComponent(rawVersion).trim() - : undefined -} - -function recursiveFreeze(value_) { - if ( - value_ === null || - !(typeof value_ === 'object' || typeof value_ === 'function') || - Object.isFrozen(value_) - ) { - return value_ - } - const queue = [value_] - let { length: queueLength } = queue - let pos = 0 - while (pos < queueLength) { - if (pos === LOOP_SENTINEL) { - throw new Error( - 'Detected infinite loop in object crawl of recursiveFreeze' - ) - } - const obj = queue[pos++] - Object.freeze(obj) - if (Array.isArray(obj)) { - for (let i = 0, { length } = obj; i < length; i += 1) { - const item = obj[i] - if ( - item !== null && - (typeof item === 'object' || typeof item === 'function') && - !Object.isFrozen(item) - ) { - queue[queueLength++] = item - } - } - } else { - const keys = Reflect.ownKeys(obj) - for (let i = 0, { length } = keys; i < length; i += 1) { - const propValue = obj[keys[i]] - if ( - propValue !== null && - (typeof propValue === 'object' || - typeof propValue === 'function') && - !Object.isFrozen(propValue) - ) { - queue[queueLength++] = propValue - } - } - } - } - return value_ -} - -function replaceDashesWithUnderscores(str) { - // Replace all "-" with "_" - let result = '' - let fromIndex = 0 - let index = 0 - while ((index = str.indexOf('-', fromIndex)) !== -1) { - result = result + str.slice(fromIndex, index) + '_' - fromIndex = index + 1 - } - return fromIndex ? result + str.slice(fromIndex) : str -} - -function replaceUnderscoresWithDashes(str) { - // Replace all "_" with "-" - let result = '' - let fromIndex = 0 - let index = 0 - while ((index = str.indexOf('_', fromIndex)) !== -1) { - result = result + str.slice(fromIndex, index) + '-' - fromIndex = index + 1 - } - return fromIndex ? result + str.slice(fromIndex) : str -} - -function subpathFilter(segment) { - // When percent-decoded, a segment - // - must not be any of '.' or '..' - // - must not be empty - const { length } = segment - if (length === 1 && segment.charCodeAt(0) === 46 /*'.'*/) return false - if ( - length === 2 && - segment.charCodeAt(0) === 46 && - segment.charCodeAt(1) === 46 - ) { - return false - } - return !isBlank(segment) -} - -function trimLeadingSlashes(str) { - let start = 0 - while (str.charCodeAt(start) === 47 /*'/'*/) { - start += 1 - } - return start === 0 ? str : str.slice(start) -} - -function validateEmptyByType(type, name, value, throws) { - if (!isNullishOrEmptyString(value)) { - if (throws) { - throw new Error( - `Invalid purl: ${type} "${name}" field must be empty.` - ) - } - return false - } - return true -} - -function validateName(name, throws) { - return ( - validateRequired('name', name, throws) && - validateStrings('name', name, throws) - ) -} - -function validateNamespace(namespace, throws) { - return validateStrings('namespace', namespace, throws) -} - -function validateQualifiers(qualifiers, throws) { - if (qualifiers === null || qualifiers === undefined) { - return true - } - if (typeof qualifiers !== 'object') { - if (throws) { - throw new Error( - 'Invalid purl: "qualifiers" argument must be an object.' - ) - } - return false - } - const keysIterable = - // URL searchParams have an "keys" method that returns an iterator. - typeof qualifiers.keys === 'function' - ? qualifiers.keys() - : Object.keys(qualifiers) - for (const key of keysIterable) { - if (!validateQualifierKey(key, throws)) { - return false - } - } - return true -} - -function validateQualifierKey(key, throws) { - // A key cannot start with a number. - if (!validateStartsWithoutNumber('qualifier', key, throws)) { - return false - } - // The key must be composed only of ASCII letters and numbers, - // '.', '-' and '_' (period, dash and underscore). - for (let i = 0, { length } = key; i < length; i += 1) { - const code = key.charCodeAt(i) - // prettier-ignore - if ( - !( - ( - (code >= 48 && code <= 57) || // 0-9 - (code >= 65 && code <= 90) || // A-Z - (code >= 97 && code <= 122) || // a-z - code === 46 || // . - code === 45 || // - - code === 95 // _ - ) - ) - ) { - if (throws) { - throw new Error( - `Invalid purl: qualifier "${key}" contains an illegal character.` - ) - } - return false - } - } - return true -} - -function validateRequired(name, value, throws) { - if (isNullishOrEmptyString(value)) { - if (throws) { - throw new Error(`Invalid purl: "${name}" is a required field.`) - } - return false - } - return true -} - -function validateRequiredByType(type, name, value, throws) { - if (isNullishOrEmptyString(value)) { - if (throws) { - throw new Error(`Invalid purl: ${type} requires a "${name}" field.`) - } - return false - } - return true -} - -function validateStartsWithoutNumber(name, value, throws) { - if (value.length !== 0) { - const code = value.charCodeAt(0) - if (code >= 48 /*'0'*/ && code <= 57 /*'9'*/) { - if (throws) { - throw new Error( - `Invalid purl: ${name} "${value}" cannot start with a number.` - ) - } - return false - } - } - return true -} - -function validateStrings(name, value, throws) { - if (value === null || value === undefined || typeof value === 'string') { - return true - } - if (throws) { - throw new Error(`Invalid purl: "'${name}" argument must be a string.`) - } - return false -} - -function validateSubpath(subpath, throws) { - return validateStrings('subpath', subpath, throws) -} - -function validateType(type, throws) { - // The type cannot be nullish, an empty string, or start with a number. - if ( - !validateRequired('type', type, throws) || - !validateStrings('type', type, throws) || - !validateStartsWithoutNumber('type', type, throws) - ) { - return false - } - // The package type is composed only of ASCII letters and numbers, - // '.', '+' and '-' (period, plus, and dash) - for (let i = 0, { length } = type; i < length; i += 1) { - const code = type.charCodeAt(i) - // prettier-ignore - if ( - !( - ( - (code >= 48 && code <= 57) || // 0-9 - (code >= 65 && code <= 90) || // A-Z - (code >= 97 && code <= 122) || // a-z - code === 46 || // . - code === 43 || // + - code === 45 // - - ) - ) - ) { - if (throws) { - throw new Error( - `Invalid purl: type "${type}" contains an illegal character.` - ) - } - return false - } - } - return true -} - -function validateVersion(version, throws) { - return validateStrings('version', version, throws) -} - class PackageURL { static Component = recursiveFreeze(Component) static KnownQualifierNames = recursiveFreeze(KnownQualifierNames) diff --git a/src/strings.js b/src/strings.js new file mode 100644 index 0000000..88b670a --- /dev/null +++ b/src/strings.js @@ -0,0 +1,119 @@ +'use strict' + +// This regexp is valid as of 2024-08-01. +// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +const regexSemverNumberedGroups = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ + +function isBlank(str) { + for (let i = 0, { length } = str; i < length; i += 1) { + const code = str.charCodeAt(i) + // prettier-ignore + if ( + !( + // Whitespace characters according to ECMAScript spec: + // https://tc39.es/ecma262/#sec-white-space + ( + code === 0x0020 || // Space + code === 0x0009 || // Tab + code === 0x000a || // Line Feed + code === 0x000b || // Vertical Tab + code === 0x000c || // Form Feed + code === 0x000d || // Carriage Return + code === 0x00a0 || // No-Break Space + code === 0x1680 || // Ogham Space Mark + code === 0x2000 || // En Quad + code === 0x2001 || // Em Quad + code === 0x2002 || // En Space + code === 0x2003 || // Em Space + code === 0x2004 || // Three-Per-Em Space + code === 0x2005 || // Four-Per-Em Space + code === 0x2006 || // Six-Per-Em Space + code === 0x2007 || // Figure Space + code === 0x2008 || // Punctuation Space + code === 0x2009 || // Thin Space + code === 0x200a || // Hair Space + code === 0x2028 || // Line Separator + code === 0x2029 || // Paragraph Separator + code === 0x202f || // Narrow No-Break Space + code === 0x205f || // Medium Mathematical Space + code === 0x3000 || // Ideographic Space + code === 0xfeff // Byte Order Mark + ) + ) + ) { + return false + } + } + return true +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.length > 0 +} + +function isSemverString(value) { + return typeof value === 'string' && regexSemverNumberedGroups.test(value) +} + +function lowerName(purl) { + purl.name = purl.name.toLowerCase() +} + +function lowerNamespace(purl) { + const { namespace } = purl + if (typeof namespace === 'string') { + purl.namespace = namespace.toLowerCase() + } +} + +function lowerVersion(purl) { + const { version } = purl + if (typeof version === 'string') { + purl.version = version.toLowerCase() + } +} + +function replaceDashesWithUnderscores(str) { + // Replace all "-" with "_" + let result = '' + let fromIndex = 0 + let index = 0 + while ((index = str.indexOf('-', fromIndex)) !== -1) { + result = result + str.slice(fromIndex, index) + '_' + fromIndex = index + 1 + } + return fromIndex ? result + str.slice(fromIndex) : str +} + +function replaceUnderscoresWithDashes(str) { + // Replace all "_" with "-" + let result = '' + let fromIndex = 0 + let index = 0 + while ((index = str.indexOf('_', fromIndex)) !== -1) { + result = result + str.slice(fromIndex, index) + '-' + fromIndex = index + 1 + } + return fromIndex ? result + str.slice(fromIndex) : str +} + +function trimLeadingSlashes(str) { + let start = 0 + while (str.charCodeAt(start) === 47 /*'/'*/) { + start += 1 + } + return start === 0 ? str : str.slice(start) +} + +module.exports = { + isBlank, + isNonEmptyString, + isSemverString, + lowerName, + lowerNamespace, + lowerVersion, + replaceDashesWithUnderscores, + replaceUnderscoresWithDashes, + trimLeadingSlashes +} diff --git a/src/validate.js b/src/validate.js new file mode 100644 index 0000000..42a41cc --- /dev/null +++ b/src/validate.js @@ -0,0 +1,189 @@ +'use strict' + +const { isNullishOrEmptyString } = require('./lang') + +function validateEmptyByType(type, name, value, throws) { + if (!isNullishOrEmptyString(value)) { + if (throws) { + throw new Error( + `Invalid purl: ${type} "${name}" field must be empty.` + ) + } + return false + } + return true +} + +function validateName(name, throws) { + return ( + validateRequired('name', name, throws) && + validateStrings('name', name, throws) + ) +} + +function validateNamespace(namespace, throws) { + return validateStrings('namespace', namespace, throws) +} + +function validateQualifiers(qualifiers, throws) { + if (qualifiers === null || qualifiers === undefined) { + return true + } + if (typeof qualifiers !== 'object') { + if (throws) { + throw new Error( + 'Invalid purl: "qualifiers" argument must be an object.' + ) + } + return false + } + const keysIterable = + // URL searchParams have an "keys" method that returns an iterator. + typeof qualifiers.keys === 'function' + ? qualifiers.keys() + : Object.keys(qualifiers) + for (const key of keysIterable) { + if (!validateQualifierKey(key, throws)) { + return false + } + } + return true +} + +function validateQualifierKey(key, throws) { + // A key cannot start with a number. + if (!validateStartsWithoutNumber('qualifier', key, throws)) { + return false + } + // The key must be composed only of ASCII letters and numbers, + // '.', '-' and '_' (period, dash and underscore). + for (let i = 0, { length } = key; i < length; i += 1) { + const code = key.charCodeAt(i) + // prettier-ignore + if ( + !( + ( + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 90) || // A-Z + (code >= 97 && code <= 122) || // a-z + code === 46 || // . + code === 45 || // - + code === 95 // _ + ) + ) + ) { + if (throws) { + throw new Error( + `Invalid purl: qualifier "${key}" contains an illegal character.` + ) + } + return false + } + } + return true +} + +function validateRequired(name, value, throws) { + if (isNullishOrEmptyString(value)) { + if (throws) { + throw new Error(`Invalid purl: "${name}" is a required field.`) + } + return false + } + return true +} + +function validateRequiredByType(type, name, value, throws) { + if (isNullishOrEmptyString(value)) { + if (throws) { + throw new Error(`Invalid purl: ${type} requires a "${name}" field.`) + } + return false + } + return true +} + +function validateStartsWithoutNumber(name, value, throws) { + if (value.length !== 0) { + const code = value.charCodeAt(0) + if (code >= 48 /*'0'*/ && code <= 57 /*'9'*/) { + if (throws) { + throw new Error( + `Invalid purl: ${name} "${value}" cannot start with a number.` + ) + } + return false + } + } + return true +} + +function validateStrings(name, value, throws) { + if (value === null || value === undefined || typeof value === 'string') { + return true + } + if (throws) { + throw new Error(`Invalid purl: "'${name}" argument must be a string.`) + } + return false +} + +function validateSubpath(subpath, throws) { + return validateStrings('subpath', subpath, throws) +} + +function validateType(type, throws) { + // The type cannot be nullish, an empty string, or start with a number. + if ( + !validateRequired('type', type, throws) || + !validateStrings('type', type, throws) || + !validateStartsWithoutNumber('type', type, throws) + ) { + return false + } + // The package type is composed only of ASCII letters and numbers, + // '.', '+' and '-' (period, plus, and dash) + for (let i = 0, { length } = type; i < length; i += 1) { + const code = type.charCodeAt(i) + // prettier-ignore + if ( + !( + ( + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 90) || // A-Z + (code >= 97 && code <= 122) || // a-z + code === 46 || // . + code === 43 || // + + code === 45 // - + ) + ) + ) { + if (throws) { + throw new Error( + `Invalid purl: type "${type}" contains an illegal character.` + ) + } + return false + } + } + return true +} + +function validateVersion(version, throws) { + return validateStrings('version', version, throws) +} + +module.exports = { + validateEmptyByType, + validateName, + validateNamespace, + validateQualifiers, + validateQualifierKey, + validateRequired, + validateRequiredByType, + validateStartsWithoutNumber, + validateStrings, + validateSubpath, + validateType, + validateVersion +} From 9294186d2bbb495c6bfb68f47779ba9457a6bd01 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 13 Aug 2024 10:09:31 -0400 Subject: [PATCH 02/15] feat: normalize empty qualifiers to undefined --- src/normalize.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/normalize.js b/src/normalize.js index a1a8bea..318c952 100644 --- a/src/normalize.js +++ b/src/normalize.js @@ -60,18 +60,23 @@ function normalizeQualifiers(rawQualifiers) { ) { return undefined } - const qualifiers = { __proto__: null } const entriesIterator = // URL searchParams have an "entries" method that returns an iterator. typeof rawQualifiers.entries === 'function' ? rawQualifiers.entries() : Object.entries(rawQualifiers) + let qualifiers for (const { 0: key, 1: value } of entriesIterator) { const strValue = typeof value === 'string' ? value : String(value) const trimmed = strValue.trim() // Value cannot be an empty string: a key=value pair with an empty value // is the same as no key/value at all for this key. - if (trimmed.length === 0) continue + if (trimmed.length === 0) { + continue + } + if (qualifiers === undefined) { + qualifiers = { __proto__: null } + } // A key is case insensitive. The canonical form is lowercase. qualifiers[key.toLowerCase()] = trimmed } From 4213f174d4e8b37e1be0a6d2f62ae7b473a54589 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 13 Aug 2024 11:37:15 -0400 Subject: [PATCH 03/15] fix: correct helper export names --- index.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 3820f5f..4317f48 100644 --- a/index.js +++ b/index.js @@ -22,15 +22,15 @@ SOFTWARE. 'use strict' const { - Component, - KnownQualifierNames, PackageURL, - Type + PurlComponent, + PurlQualifierNames, + PurlType } = require('./src/package-url') module.exports = { - Component, - KnownQualifierNames, PackageURL, - Type + PurlComponent, + PurlQualifierNames, + PurlType } From c581ca0e600058b451555972051b7842bb8a56d1 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 13 Aug 2024 12:42:54 -0400 Subject: [PATCH 04/15] feat: expose PackageURL.parseString --- src/package-url.d.ts | 22 ++++++++++++++++++---- src/package-url.js | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/package-url.d.ts b/src/package-url.d.ts index 63802bf..e0b44c1 100644 --- a/src/package-url.d.ts +++ b/src/package-url.d.ts @@ -120,12 +120,14 @@ declare module "packageurl-js" { static Type: PurlType /** - * The package "type" or package "protocol" such as maven, npm, nuget, gem, pypi, etc. Required. + * The package "type" or package "protocol" such as maven, npm, nuget, gem, + * pypi, etc. Required. */ type: string /** - * Some name prefix such as a Maven groupid, a Docker image owner, a GitHub user or organization. Optional and type-specific. + * Some name prefix such as a Maven groupid, a Docker image owner, a GitHub + * user or organization. Optional and type-specific. */ namespace: string | undefined @@ -140,7 +142,8 @@ declare module "packageurl-js" { version: string | undefined /** - * Extra qualifying data for a package such as an OS, architecture, a distro, etc. Optional and type-specific. + * Extra qualifying data for a package such as an OS, architecture, a distro, + * etc. Optional and type-specific. */ qualifiers: PurlQualifiers | undefined @@ -165,9 +168,20 @@ declare module "packageurl-js" { /** * Parses a purl string into a PackageURL instance. - * @param purlStr string to parse */ static fromString(purlStr: string): PackageURL + + /** + * Parses a purl string into a PackageURL arguments array. + */ + static parseString(purlStr: string): [ + type: string | undefined, + namespace: string | undefined, + name: string | undefined, + version: string | undefined, + qualifiers: PurlQualifiers | undefined, + subpath: string | undefined + ] } export const PurlComponent = {} diff --git a/src/package-url.js b/src/package-url.js index 1887c93..e15e431 100644 --- a/src/package-url.js +++ b/src/package-url.js @@ -481,10 +481,24 @@ class PackageURL { } static fromString(purlStr) { + return new PackageURL(...PackageURL.parseString(purlStr)) + } + + static parseString(purlStr) { // https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#how-to-parse-a-purl-string-in-its-components - if (typeof purlStr !== 'string' || isBlank(purlStr)) { + if (typeof purlStr !== 'string') { throw new Error('A purl string argument is required.') } + if (isBlank(purlStr)) { + return [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ] + } // Split the remainder once from left on ':'. const colonIndex = purlStr.indexOf(':') @@ -510,7 +524,7 @@ class PackageURL { // The scheme is a constant with the value "pkg". if (url.protocol !== 'pkg:') { throw new Error( - 'purl is missing the required "pkg" scheme component.' + 'Invalid purl: missing required "pkg" scheme component' ) } @@ -524,10 +538,20 @@ class PackageURL { const { pathname } = url const firstSlashIndex = pathname.indexOf('/') + const rawType = + firstSlashIndex === -1 + ? pathname + : pathname.slice(0, firstSlashIndex) if (firstSlashIndex < 1) { - throw new Error('Invalid purl: missing required "type" component') + return [ + rawType, + undefined, + undefined, + undefined, + undefined, + undefined + ] } - const rawType = pathname.slice(0, firstSlashIndex) let rawVersion let atSignIndex = pathname.lastIndexOf('@') @@ -576,14 +600,14 @@ class PackageURL { rawSubpath = hash.slice(1) } - return new PackageURL( + return [ rawType, rawNamespace, rawName, rawVersion, rawQualifiers, rawSubpath - ) + ] } } From 978280e48c0ab599a8489ea5390c5dd4039dfb4a Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 13 Aug 2024 14:13:30 -0400 Subject: [PATCH 05/15] feat: accept string qualifiers --- src/normalize.js | 30 +++++++++++++++--------------- src/package-url.d.ts | 2 +- src/package-url.js | 7 ++++--- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/normalize.js b/src/normalize.js index 318c952..bf744bd 100644 --- a/src/normalize.js +++ b/src/normalize.js @@ -1,5 +1,6 @@ 'use strict' +const { isObject } = require('./objects') const { isBlank } = require('./strings') const { decodeURIComponent } = globalThis @@ -53,24 +54,12 @@ function normalizePath(pathname, callback) { } function normalizeQualifiers(rawQualifiers) { - if ( - rawQualifiers === null || - rawQualifiers === undefined || - typeof rawQualifiers !== 'object' - ) { - return undefined - } - const entriesIterator = - // URL searchParams have an "entries" method that returns an iterator. - typeof rawQualifiers.entries === 'function' - ? rawQualifiers.entries() - : Object.entries(rawQualifiers) let qualifiers - for (const { 0: key, 1: value } of entriesIterator) { + for (const { 0: key, 1: value } of qualifiersToEntries(rawQualifiers)) { const strValue = typeof value === 'string' ? value : String(value) const trimmed = strValue.trim() - // Value cannot be an empty string: a key=value pair with an empty value - // is the same as no key/value at all for this key. + // A key=value pair with an empty value is the same as no key/value + // at all for this key. if (trimmed.length === 0) { continue } @@ -103,6 +92,17 @@ function normalizeVersion(rawVersion) { : undefined } +function qualifiersToEntries(rawQualifiers) { + if (isObject(rawQualifiers)) { + return rawQualifiers instanceof URLSearchParams + ? rawQualifiers.entries() + : Object.entries(rawQualifiers) + } + return typeof rawQualifiers === 'string' + ? new URLSearchParams(rawQualifiers).entries() + : Object.entries({}) +} + function subpathFilter(segment) { // When percent-decoded, a segment // - must not be any of '.' or '..' diff --git a/src/package-url.d.ts b/src/package-url.d.ts index e0b44c1..123a531 100644 --- a/src/package-url.d.ts +++ b/src/package-url.d.ts @@ -157,7 +157,7 @@ declare module "packageurl-js" { namespace: string | undefined | null, name: string, version?: string | undefined | null, - qualifiers?: PurlQualifiers | undefined | null, + qualifiers?: PurlQualifiers | string | undefined | null, subpath?: string | undefined | null ) diff --git a/src/package-url.js b/src/package-url.js index e15e431..344c238 100644 --- a/src/package-url.js +++ b/src/package-url.js @@ -437,9 +437,10 @@ class PackageURL { : rawVersion Component.version.validate(version, true) - const qualifiers = isObject(rawQualifiers) - ? Component.qualifiers.normalize(rawQualifiers) - : rawQualifiers + const qualifiers = + typeof rawQualifiers === 'string' || isObject(rawQualifiers) + ? Component.qualifiers.normalize(rawQualifiers) + : rawQualifiers Component.qualifiers.validate(qualifiers, true) const subpath = isNonEmptyString(rawSubpath) From 6c38da8e91aea030fb24b0e323a25e850e7645f4 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 13 Aug 2024 15:26:05 -0400 Subject: [PATCH 06/15] fix: correct order of error in purl parsing --- src/package-url.js | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/package-url.js b/src/package-url.js index 344c238..c44e6a7 100644 --- a/src/package-url.js +++ b/src/package-url.js @@ -508,30 +508,39 @@ class PackageURL { // - Split the remainder once from right on '?' // - Split the remainder once from left on ':' let url - try { - url = new URL( - colonIndex === -1 - ? purlStr - : // Since a purl never contains a URL Authority, its scheme - // must not be suffixed with double slash as in 'pkg://' - // and should use instead 'pkg:'. Purl parsers must accept - // URLs such as 'pkg://' and must ignore the '//' - `pkg:${trimLeadingSlashes(purlStr.slice(colonIndex + 1))}` - ) - } catch { - throw new Error('Invalid purl: failed to parse as URL') + let maybeUrlWithAuth + if (colonIndex !== -1) { + try { + // Since a purl never contains a URL Authority, its scheme + // must not be suffixed with double slash as in 'pkg://' + // and should use instead 'pkg:'. Purl parsers must accept + // URLs such as 'pkg://' and must ignore the '//' + const beforeColon = purlStr.slice(0, colonIndex) + const afterColon = purlStr.slice(colonIndex + 1) + const trimmedAfterColon = trimLeadingSlashes(afterColon) + url = new URL(`${beforeColon}:${trimmedAfterColon}`) + maybeUrlWithAuth = + afterColon.length === trimmedAfterColon.length + ? url + : new URL(purlStr) + } catch (e) { + throw new Error('Invalid purl: failed to parse as URL', { + cause: e + }) + } } - // The scheme is a constant with the value "pkg". - if (url.protocol !== 'pkg:') { + if (url?.protocol !== 'pkg:') { throw new Error( 'Invalid purl: missing required "pkg" scheme component' ) } - // A purl must NOT contain a URL Authority i.e. there is no support for // username, password, host and port components. - if (url.username !== '' || url.password !== '') { + if ( + maybeUrlWithAuth.username !== '' || + maybeUrlWithAuth.password !== '' + ) { throw new Error( 'Invalid purl: cannot contain a "user:pass@host:port"' ) From 705811f663c9cbe4a2b0c152792d4cacce0faa61 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 13 Aug 2024 15:26:45 -0400 Subject: [PATCH 07/15] chore: update readme --- README.md | 49 ++++++++++++++++++++-------------------- test/benchmark.spec.js | 1 - test/package-url.spec.js | 1 - 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 3ae99e7..bc69782 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,15 @@ npm install packageurl-js This command will download the `packageurl-js` npm package for use in your application. ### Local Development: -Clone the `packageurl-js` repo and `cd` into the directory. +Clone the `packageurl-js` repo and `cd` into the directory. -Then run: +Then run: ``` npm install ``` ### Testing -To run the test suite: +To run the test suite: ``` npm test ``` @@ -27,23 +27,24 @@ npm test #### Import ES6 Module ``` -import { PackageURL } from 'packageurl-js'; +import { PackageURL } from 'packageurl-js' ``` #### Import CommonJs Module ``` -const { PackageURL } = require('packageurl-js'); +const { PackageURL } = require('packageurl-js') ``` -#### Parsing from a string +#### Parsing a string -``` -const pkg = PackageURL.fromString('pkg:maven/org.springframework.integration/spring-integration-jms@5.5.5'); -console.log(pkg); +```js +const purlStr = 'pkg:maven/org.springframework.integration/spring-integration-jms@5.5.5' +console.log(PackageURL.fromString(purlStr)) +console.log(new PackageURL(...PackageURL.parseString(purlStr))) ``` -=> +will both log ``` PackageURL { @@ -51,26 +52,24 @@ PackageURL { name: 'spring-integration-jms', namespace: 'org.springframework.integration', version: '5.5.5', - qualifiers: null, - subpath: null + qualifiers: undefined, + subpath: undefined } ``` #### Constructing -``` +```js const pkg = new PackageURL( 'maven', 'org.springframework.integration', 'spring-integration-jms', - '5.5.5', - undefined, - undefined); - -console.log(pkg.toString()); + '5.5.5' +) +console.log(pkg.toString()) ``` -=> +will log ``` pkg:maven/org.springframework.integration/spring-integration-jms@5.5.5 @@ -78,16 +77,16 @@ pkg:maven/org.springframework.integration/spring-integration-jms@5.5.5 #### Error Handling -``` +```js try { - PackageURL.fromString('not-a-purl'); -} catch(ex) { - console.error(ex.message); + PackageURL.fromString('not-a-purl') +} catch (e) { + console.error(e.message) } ``` -=> +will log ``` -purl is missing the required "pkg" scheme component. +Invalid purl: missing required "pkg" scheme component ``` diff --git a/test/benchmark.spec.js b/test/benchmark.spec.js index a91540a..7654aeb 100644 --- a/test/benchmark.spec.js +++ b/test/benchmark.spec.js @@ -2,7 +2,6 @@ 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', () => { diff --git a/test/package-url.spec.js b/test/package-url.spec.js index 80b123c..2d7b478 100644 --- a/test/package-url.spec.js +++ b/test/package-url.spec.js @@ -28,7 +28,6 @@ const TEST_FILE = [ ...require('./data/contrib-tests.json') ] -/** @type {import('../src/package-url')} */ const { PackageURL } = require('../src/package-url') describe('PackageURL', function () { From 045f97ccff7c7dc33d6cc7f09a90d8099320a347 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 13 Aug 2024 21:07:50 -0400 Subject: [PATCH 08/15] fix: correct mlflow typo --- src/package-url.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package-url.js b/src/package-url.js index c44e6a7..5c1f7af 100644 --- a/src/package-url.js +++ b/src/package-url.js @@ -313,7 +313,7 @@ const Type = createHelpersNamespaceObject( // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow mlflow(purl, throws) { return validateEmptyByType( - 'mflow', + 'mlflow', 'namespace', purl.namespace, throws From 45bd759c2cf74854b4fe3a7b4d6a9d9971a9542c Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 13 Aug 2024 21:57:48 -0400 Subject: [PATCH 09/15] feat: split helpers into modules --- src/helpers.js | 31 +++ src/package-url.js | 434 +++--------------------------------- src/purl-component.js | 77 +++++++ src/purl-qualifier-names.js | 14 ++ src/purl-type.js | 277 +++++++++++++++++++++++ 5 files changed, 428 insertions(+), 405 deletions(-) create mode 100644 src/helpers.js create mode 100644 src/purl-component.js create mode 100644 src/purl-qualifier-names.js create mode 100644 src/purl-type.js diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000..69dfac4 --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,31 @@ +'use strict' + +function createHelpersNamespaceObject(helpers, defaults = {}) { + const helperNames = Object.keys(helpers).sort() + const propNames = [ + ...new Set([...Object.values(helpers)].map(Object.keys).flat()) + ].sort() + const nsObject = Object.create(null) + for (let i = 0, { length } = propNames; i < length; i += 1) { + const propName = propNames[i] + const helpersForProp = Object.create(null) + for ( + let j = 0, { length: length_j } = helperNames; + j < length_j; + j += 1 + ) { + const helperName = helperNames[j] + const helperValue = + helpers[helperName][propName] ?? defaults[helperName] + if (helperValue !== undefined) { + helpersForProp[helperName] = helperValue + } + } + nsObject[propName] = helpersForProp + } + return nsObject +} + +module.exports = { + createHelpersNamespaceObject +} diff --git a/src/package-url.js b/src/package-url.js index 5c1f7af..4ee27cc 100644 --- a/src/package-url.js +++ b/src/package-url.js @@ -21,393 +21,17 @@ SOFTWARE. */ 'use strict' -const { - encodeNamespace, - encodeVersion, - encodeQualifiers, - encodeQualifierValue, - encodeSubpath, - encodeURIComponent -} = require('./encode') - -const { isNullishOrEmptyString } = require('./lang') - -const { - normalizeName, - normalizeNamespace, - normalizeQualifiers, - normalizeSubpath, - normalizeType, - normalizeVersion -} = require('./normalize') - const { isObject, recursiveFreeze } = require('./objects') +const { isBlank, isNonEmptyString, trimLeadingSlashes } = require('./strings') -const { - isBlank, - isNonEmptyString, - isSemverString, - lowerName, - lowerNamespace, - lowerVersion, - replaceDashesWithUnderscores, - replaceUnderscoresWithDashes, - trimLeadingSlashes -} = require('./strings') - -const { - validateEmptyByType, - validateName, - validateNamespace, - validateQualifiers, - validateQualifierKey, - validateRequiredByType, - validateSubpath, - validateType, - validateVersion -} = require('./validate') - -const PurlComponentEncoder = (comp) => - typeof comp === 'string' && comp.length ? encodeURIComponent(comp) : '' - -const PurlComponentStringNormalizer = (comp) => - typeof comp === 'string' ? comp : undefined - -const PurlComponentValidator = (_comp, _throws) => true - -const PurlTypNormalizer = (purl) => purl - -const PurlTypeValidator = (_purl, _throws) => true - -// Rules for each purl component: -// https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#rules-for-each-purl-component -const Component = createHelpersNamespaceObject( - { - encode: { - namespace: encodeNamespace, - version: encodeVersion, - qualifiers: encodeQualifiers, - qualifierValue: encodeQualifierValue, - subpath: encodeSubpath - }, - normalize: { - type: normalizeType, - namespace: normalizeNamespace, - name: normalizeName, - version: normalizeVersion, - qualifiers: normalizeQualifiers, - subpath: normalizeSubpath - }, - validate: { - type: validateType, - namespace: validateNamespace, - name: validateName, - version: validateVersion, - qualifierKey: validateQualifierKey, - qualifiers: validateQualifiers, - subpath: validateSubpath - } - }, - { - encode: PurlComponentEncoder, - normalize: PurlComponentStringNormalizer, - validate: PurlComponentValidator - } -) - -// Known qualifiers: -// https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#known-qualifiers-keyvalue-pairs -const KnownQualifierNames = { - __proto__: null, - RepositoryUrl: 'repository_url', - DownloadUrl: 'download_url', - VcsUrl: 'vcs_url', - FileName: 'file_name', - Checksum: 'checksum' -} - -// PURL types: -// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst -const Type = createHelpersNamespaceObject( - { - normalize: { - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#alpm - alpm(purl) { - lowerNamespace(purl) - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#apk - apk(purl) { - lowerNamespace(purl) - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#bitbucket - bitbucket(purl) { - lowerNamespace(purl) - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#bitnami - bitnami(purl) { - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#composer - composer(purl) { - lowerNamespace(purl) - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#deb - deb(purl) { - lowerNamespace(purl) - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#other-candidate-types-to-define - gitlab(purl) { - lowerNamespace(purl) - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#github - github(purl) { - lowerNamespace(purl) - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#golang - // golang(purl) { - // // Ignore case-insensitive rule because go.mod are case-sensitive. - // // Pending spec change: https://github.com/package-url/purl-spec/pull/196 - // lowerNamespace(purl) - // lowerName(purl) - // return purl - // }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#hex - hex(purl) { - lowerNamespace(purl) - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#huggingface - huggingface(purl) { - lowerVersion(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow - mlflow(purl) { - if (purl.qualifiers?.repository_url?.includes('databricks')) { - lowerName(purl) - } - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#npm - npm(purl) { - lowerNamespace(purl) - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#luarocks - luarocks(purl) { - lowerVersion(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci - oci(purl) { - lowerName(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#pub - pub(purl) { - lowerName(purl) - purl.name = replaceDashesWithUnderscores(purl.name) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#pypi - pypi(purl) { - lowerNamespace(purl) - lowerName(purl) - purl.name = replaceUnderscoresWithDashes(purl.name) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#qpkg - qpkg(purl) { - lowerNamespace(purl) - return purl - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#rpm - rpm(purl) { - lowerNamespace(purl) - return purl - } - }, - validate: { - // TODO: cocoapods name validation - // TODO: cpan namespace validation - // TODO: swid qualifier validation - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#conan - conan(purl, throws) { - if (isNullishOrEmptyString(purl.namespace)) { - if (purl.qualifiers?.channel) { - if (throws) { - throw new Error( - 'Invalid purl: conan requires a "namespace" field when a "channel" qualifier is present.' - ) - } - return false - } - } else if (isNullishOrEmptyString(purl.qualifiers)) { - if (throws) { - throw new Error( - 'Invalid purl: conan requires a "qualifiers" field when a namespace is present.' - ) - } - return false - } - return true - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#cran - cran(purl, throws) { - return validateRequiredByType( - 'cran', - 'version', - purl.version, - throws - ) - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#golang - golang(purl) { - // Still being lenient here since the standard changes aren't official. - // Pending spec change: https://github.com/package-url/purl-spec/pull/196 - const { version } = purl - const length = typeof version === 'string' ? version.length : 0 - // If the version starts with a "v" then ensure its a valid semver version. - // This, by semver semantics, also supports pseudo-version number. - // https://go.dev/doc/modules/version-numbers#pseudo-version-number - if ( - length && - version.charCodeAt(0) === 118 /*'v'*/ && - !isSemverString(version.slice(1)) - ) { - if (throws) { - throw new Error( - 'Invalid purl: golang "version" field starting with a "v" must be followed by a valid semver version' - ) - } - return false - } - return true - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#maven - maven(purl, throws) { - return validateRequiredByType( - 'maven', - 'namespace', - purl.namespace, - throws - ) - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow - mlflow(purl, throws) { - return validateEmptyByType( - 'mlflow', - 'namespace', - purl.namespace, - throws - ) - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci - oci(purl, throws) { - return validateEmptyByType( - 'oci', - 'namespace', - purl.namespace, - throws - ) - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#pub - pub(purl, throws) { - const { name } = purl - for (let i = 0, { length } = name; i < length; i += 1) { - const code = name.charCodeAt(i) - // prettier-ignore - if ( - !( - ( - (code >= 48 && code <= 57) || // 0-9 - (code >= 97 && code <= 122) || // a-z - code === 95 // _ - ) - ) - ) { - if (throws) { - throw new Error( - 'Invalid purl: pub "name" field may only contain [a-z0-9_] characters' - ) - } - return false - } - } - return true - }, - // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#swift - swift(purl, throws) { - return ( - validateRequiredByType( - 'swift', - 'namespace', - purl.namespace, - throws - ) && - validateRequiredByType( - 'swift', - 'version', - purl.version, - throws - ) - ) - } - } - }, - { - normalize: PurlTypNormalizer, - validate: PurlTypeValidator - } -) - -function createHelpersNamespaceObject(helpers, defaults = {}) { - const helperNames = Object.keys(helpers).sort() - const propNames = [ - ...new Set([...Object.values(helpers)].map(Object.keys).flat()) - ].sort() - const nsObject = Object.create(null) - for (let i = 0, { length } = propNames; i < length; i += 1) { - const propName = propNames[i] - const helpersForProp = Object.create(null) - for ( - let j = 0, { length: length_j } = helperNames; - j < length_j; - j += 1 - ) { - const helperName = helperNames[j] - const helperValue = - helpers[helperName][propName] ?? defaults[helperName] - if (helperValue !== undefined) { - helpersForProp[helperName] = helperValue - } - } - nsObject[propName] = helpersForProp - } - return nsObject -} +const { PurlComponent } = require('./purl-component') +const { PurlQualifierNames } = require('./purl-qualifier-names') +const { PurlType } = require('./purl-type') class PackageURL { - static Component = recursiveFreeze(Component) - static KnownQualifierNames = recursiveFreeze(KnownQualifierNames) - static Type = recursiveFreeze(Type) + static Component = recursiveFreeze(PurlComponent) + static KnownQualifierNames = recursiveFreeze(PurlQualifierNames) + static Type = recursiveFreeze(PurlType) constructor( rawType, @@ -418,35 +42,35 @@ class PackageURL { rawSubpath ) { const type = isNonEmptyString(rawType) - ? Component.type.normalize(rawType) + ? PurlComponent.type.normalize(rawType) : rawType - Component.type.validate(type, true) + PurlComponent.type.validate(type, true) const namespace = isNonEmptyString(rawNamespace) - ? Component.namespace.normalize(rawNamespace) + ? PurlComponent.namespace.normalize(rawNamespace) : rawNamespace - Component.namespace.validate(namespace, true) + PurlComponent.namespace.validate(namespace, true) const name = isNonEmptyString(rawName) - ? Component.name.normalize(rawName) + ? PurlComponent.name.normalize(rawName) : rawName - Component.name.validate(name, true) + PurlComponent.name.validate(name, true) const version = isNonEmptyString(rawVersion) - ? Component.version.normalize(rawVersion) + ? PurlComponent.version.normalize(rawVersion) : rawVersion - Component.version.validate(version, true) + PurlComponent.version.validate(version, true) const qualifiers = typeof rawQualifiers === 'string' || isObject(rawQualifiers) - ? Component.qualifiers.normalize(rawQualifiers) + ? PurlComponent.qualifiers.normalize(rawQualifiers) : rawQualifiers - Component.qualifiers.validate(qualifiers, true) + PurlComponent.qualifiers.validate(qualifiers, true) const subpath = isNonEmptyString(rawSubpath) - ? Component.subpath.normalize(rawSubpath) + ? PurlComponent.subpath.normalize(rawSubpath) : rawSubpath - Component.subpath.validate(subpath, true) + PurlComponent.subpath.validate(subpath, true) this.type = type this.name = name @@ -455,7 +79,7 @@ class PackageURL { this.qualifiers = qualifiers ?? undefined this.subpath = subpath ?? undefined - const typeHelpers = Type[type] + const typeHelpers = PurlType[type] if (typeHelpers) { typeHelpers.normalize(this) typeHelpers.validate(this, true) @@ -464,19 +88,19 @@ class PackageURL { toString() { const { namespace, name, version, qualifiers, subpath, type } = this - let purlStr = `pkg:${Component.type.encode(type)}/` + let purlStr = `pkg:${PurlComponent.type.encode(type)}/` if (namespace) { - purlStr = `${purlStr}${Component.namespace.encode(namespace)}/` + purlStr = `${purlStr}${PurlComponent.namespace.encode(namespace)}/` } - purlStr = `${purlStr}${Component.name.encode(name)}` + purlStr = `${purlStr}${PurlComponent.name.encode(name)}` if (version) { - purlStr = `${purlStr}@${Component.version.encode(version)}` + purlStr = `${purlStr}@${PurlComponent.version.encode(version)}` } if (qualifiers) { - purlStr = `${purlStr}?${Component.qualifiers.encode(qualifiers)}` + purlStr = `${purlStr}?${PurlComponent.qualifiers.encode(qualifiers)}` } if (subpath) { - purlStr = `${purlStr}#${Component.subpath.encode(subpath)}` + purlStr = `${purlStr}#${PurlComponent.subpath.encode(subpath)}` } return purlStr } @@ -632,7 +256,7 @@ Reflect.setPrototypeOf(PackageURL.prototype, null) module.exports = { PackageURL, - PurlComponent: Component, - PurlQualifierNames: KnownQualifierNames, - PurlType: Type + PurlComponent, + PurlQualifierNames, + PurlType } diff --git a/src/purl-component.js b/src/purl-component.js new file mode 100644 index 0000000..91279ac --- /dev/null +++ b/src/purl-component.js @@ -0,0 +1,77 @@ +'use strict' + +const { + encodeNamespace, + encodeVersion, + encodeQualifiers, + encodeQualifierValue, + encodeSubpath, + encodeURIComponent +} = require('./encode') + +const { + normalizeName, + normalizeNamespace, + normalizeQualifiers, + normalizeSubpath, + normalizeType, + normalizeVersion +} = require('./normalize') + +const { createHelpersNamespaceObject } = require('./helpers') + +const { + validateName, + validateNamespace, + validateQualifiers, + validateQualifierKey, + validateSubpath, + validateType, + validateVersion +} = require('./validate') + +const PurlComponentEncoder = (comp) => + typeof comp === 'string' && comp.length ? encodeURIComponent(comp) : '' + +const PurlComponentStringNormalizer = (comp) => + typeof comp === 'string' ? comp : undefined + +const PurlComponentValidator = (_comp, _throws) => true + +module.exports = { + // Rules for each purl component: + // https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#rules-for-each-purl-component + PurlComponent: createHelpersNamespaceObject( + { + encode: { + namespace: encodeNamespace, + version: encodeVersion, + qualifiers: encodeQualifiers, + qualifierValue: encodeQualifierValue, + subpath: encodeSubpath + }, + normalize: { + type: normalizeType, + namespace: normalizeNamespace, + name: normalizeName, + version: normalizeVersion, + qualifiers: normalizeQualifiers, + subpath: normalizeSubpath + }, + validate: { + type: validateType, + namespace: validateNamespace, + name: validateName, + version: validateVersion, + qualifierKey: validateQualifierKey, + qualifiers: validateQualifiers, + subpath: validateSubpath + } + }, + { + encode: PurlComponentEncoder, + normalize: PurlComponentStringNormalizer, + validate: PurlComponentValidator + } + ) +} diff --git a/src/purl-qualifier-names.js b/src/purl-qualifier-names.js new file mode 100644 index 0000000..a936245 --- /dev/null +++ b/src/purl-qualifier-names.js @@ -0,0 +1,14 @@ +'use strict' + +module.exports = { + // Known qualifiers: + // https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#known-qualifiers-keyvalue-pairs + PurlQualifierNames: { + __proto__: null, + RepositoryUrl: 'repository_url', + DownloadUrl: 'download_url', + VcsUrl: 'vcs_url', + FileName: 'file_name', + Checksum: 'checksum' + } +} diff --git a/src/purl-type.js b/src/purl-type.js new file mode 100644 index 0000000..7d3d694 --- /dev/null +++ b/src/purl-type.js @@ -0,0 +1,277 @@ +'use strict' + +const { isNullishOrEmptyString } = require('./lang') + +const { createHelpersNamespaceObject } = require('./helpers') + +const { + isSemverString, + lowerName, + lowerNamespace, + lowerVersion, + replaceDashesWithUnderscores, + replaceUnderscoresWithDashes +} = require('./strings') + +const { validateEmptyByType, validateRequiredByType } = require('./validate') + +const PurlTypNormalizer = (purl) => purl + +const PurlTypeValidator = (_purl, _throws) => true + +module.exports = { + // PURL types: + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst + PurlType: createHelpersNamespaceObject( + { + normalize: { + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#alpm + alpm(purl) { + lowerNamespace(purl) + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#apk + apk(purl) { + lowerNamespace(purl) + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#bitbucket + bitbucket(purl) { + lowerNamespace(purl) + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#bitnami + bitnami(purl) { + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#composer + composer(purl) { + lowerNamespace(purl) + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#deb + deb(purl) { + lowerNamespace(purl) + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#other-candidate-types-to-define + gitlab(purl) { + lowerNamespace(purl) + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#github + github(purl) { + lowerNamespace(purl) + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#golang + // golang(purl) { + // // Ignore case-insensitive rule because go.mod are case-sensitive. + // // Pending spec change: https://github.com/package-url/purl-spec/pull/196 + // lowerNamespace(purl) + // lowerName(purl) + // return purl + // }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#hex + hex(purl) { + lowerNamespace(purl) + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#huggingface + huggingface(purl) { + lowerVersion(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow + mlflow(purl) { + if ( + purl.qualifiers?.repository_url?.includes('databricks') + ) { + lowerName(purl) + } + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#npm + npm(purl) { + lowerNamespace(purl) + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#luarocks + luarocks(purl) { + lowerVersion(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci + oci(purl) { + lowerName(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#pub + pub(purl) { + lowerName(purl) + purl.name = replaceDashesWithUnderscores(purl.name) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#pypi + pypi(purl) { + lowerNamespace(purl) + lowerName(purl) + purl.name = replaceUnderscoresWithDashes(purl.name) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#qpkg + qpkg(purl) { + lowerNamespace(purl) + return purl + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#rpm + rpm(purl) { + lowerNamespace(purl) + return purl + } + }, + validate: { + // TODO: cocoapods name validation + // TODO: cpan namespace validation + // TODO: swid qualifier validation + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#conan + conan(purl, throws) { + if (isNullishOrEmptyString(purl.namespace)) { + if (purl.qualifiers?.channel) { + if (throws) { + throw new Error( + 'Invalid purl: conan requires a "namespace" field when a "channel" qualifier is present.' + ) + } + return false + } + } else if (isNullishOrEmptyString(purl.qualifiers)) { + if (throws) { + throw new Error( + 'Invalid purl: conan requires a "qualifiers" field when a namespace is present.' + ) + } + return false + } + return true + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#cran + cran(purl, throws) { + return validateRequiredByType( + 'cran', + 'version', + purl.version, + throws + ) + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#golang + golang(purl) { + // Still being lenient here since the standard changes aren't official. + // Pending spec change: https://github.com/package-url/purl-spec/pull/196 + const { version } = purl + const length = + typeof version === 'string' ? version.length : 0 + // If the version starts with a "v" then ensure its a valid semver version. + // This, by semver semantics, also supports pseudo-version number. + // https://go.dev/doc/modules/version-numbers#pseudo-version-number + if ( + length && + version.charCodeAt(0) === 118 /*'v'*/ && + !isSemverString(version.slice(1)) + ) { + if (throws) { + throw new Error( + 'Invalid purl: golang "version" field starting with a "v" must be followed by a valid semver version' + ) + } + return false + } + return true + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#maven + maven(purl, throws) { + return validateRequiredByType( + 'maven', + 'namespace', + purl.namespace, + throws + ) + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow + mlflow(purl, throws) { + return validateEmptyByType( + 'mlflow', + 'namespace', + purl.namespace, + throws + ) + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci + oci(purl, throws) { + return validateEmptyByType( + 'oci', + 'namespace', + purl.namespace, + throws + ) + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#pub + pub(purl, throws) { + const { name } = purl + for (let i = 0, { length } = name; i < length; i += 1) { + const code = name.charCodeAt(i) + // prettier-ignore + if ( + !( + ( + (code >= 48 && code <= 57) || // 0-9 + (code >= 97 && code <= 122) || // a-z + code === 95 // _ + ) + ) + ) { + if (throws) { + throw new Error( + 'Invalid purl: pub "name" field may only contain [a-z0-9_] characters' + ) + } + return false + } + } + return true + }, + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#swift + swift(purl, throws) { + return ( + validateRequiredByType( + 'swift', + 'namespace', + purl.namespace, + throws + ) && + validateRequiredByType( + 'swift', + 'version', + purl.version, + throws + ) + ) + } + } + }, + { + normalize: PurlTypNormalizer, + validate: PurlTypeValidator + } + ) +} From 7d41b1473854ed0f103793fcd82451b2ff7a9cd8 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 14 Aug 2024 10:03:54 -0400 Subject: [PATCH 10/15] feat: sort PurlComponent helpers --- src/helpers.js | 5 +++-- src/purl-component.js | 25 ++++++++++++++++++++++++- src/strings.js | 8 ++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/helpers.js b/src/helpers.js index 69dfac4..d5d63bd 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -1,10 +1,11 @@ 'use strict' -function createHelpersNamespaceObject(helpers, defaults = {}) { +function createHelpersNamespaceObject(helpers, options_ = {}) { + const { comparator, ...defaults } = { __proto__: null, ...options_ } const helperNames = Object.keys(helpers).sort() const propNames = [ ...new Set([...Object.values(helpers)].map(Object.keys).flat()) - ].sort() + ].sort(comparator) const nsObject = Object.create(null) for (let i = 0, { length } = propNames; i < length; i += 1) { const propName = propNames[i] diff --git a/src/purl-component.js b/src/purl-component.js index 91279ac..5f53017 100644 --- a/src/purl-component.js +++ b/src/purl-component.js @@ -9,6 +9,8 @@ const { encodeURIComponent } = require('./encode') +const { createHelpersNamespaceObject } = require('./helpers') + const { normalizeName, normalizeNamespace, @@ -18,7 +20,7 @@ const { normalizeVersion } = require('./normalize') -const { createHelpersNamespaceObject } = require('./helpers') +const { localeCompare } = require('./strings') const { validateName, @@ -38,6 +40,26 @@ const PurlComponentStringNormalizer = (comp) => const PurlComponentValidator = (_comp, _throws) => true +const componentSortOrderLookup = { + __proto__: null, + type: 0, + namespace: 1, + name: 2, + version: 3, + qualifiers: 4, + qualifierKey: 5, + qualifierValue: 6, + subpath: 7 +} + +function componentSortOrder(comp) { + return componentSortOrderLookup[comp] ?? comp +} + +function componentComparator(compA, compB) { + return localeCompare(componentSortOrder(compA), componentSortOrder(compB)) +} + module.exports = { // Rules for each purl component: // https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#rules-for-each-purl-component @@ -69,6 +91,7 @@ module.exports = { } }, { + comparator: componentComparator, encode: PurlComponentEncoder, normalize: PurlComponentStringNormalizer, validate: PurlComponentValidator diff --git a/src/strings.js b/src/strings.js index 88b670a..5f82425 100644 --- a/src/strings.js +++ b/src/strings.js @@ -1,5 +1,12 @@ 'use strict' +// Intl.Collator is faster than String#localeCompare +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare: +// > When comparing large numbers of strings, such as in sorting large arrays, +// > it is better to create an Intl.Collator object and use the function provided +// > by its compare() method. +const { compare: localeCompare } = new Intl.Collator() + // This regexp is valid as of 2024-08-01. // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string const regexSemverNumberedGroups = @@ -110,6 +117,7 @@ module.exports = { isBlank, isNonEmptyString, isSemverString, + localeCompare, lowerName, lowerNamespace, lowerVersion, From 09d8ebfdb39253f2a7d85e0f43738e130effdbce Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 14 Aug 2024 10:03:19 -0400 Subject: [PATCH 11/15] chore: update readme usage examples --- README.md | 98 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index bc69782..423c675 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,45 @@ # packageurl-js -### Installing: +### Installing + To install `packageurl-js` in your project, simply run: -``` +```bash npm install packageurl-js ``` This command will download the `packageurl-js` npm package for use in your application. -### Local Development: +### Local Development + Clone the `packageurl-js` repo and `cd` into the directory. Then run: -``` +```bash npm install ``` ### Testing + To run the test suite: -``` +```bash npm test ``` ### Usage Examples -#### Import ES6 Module +#### Importing -``` +As an ES6 module +```js import { PackageURL } from 'packageurl-js' ``` -#### Import CommonJs Module - -``` +As a CommonJS module +```js const { PackageURL } = require('packageurl-js') ``` -#### Parsing a string +#### Parsing ```js const purlStr = 'pkg:maven/org.springframework.integration/spring-integration-jms@5.5.5' @@ -48,12 +51,12 @@ will both log ``` PackageURL { - type: 'maven', - name: 'spring-integration-jms', - namespace: 'org.springframework.integration', - version: '5.5.5', - qualifiers: undefined, - subpath: undefined + type: 'maven', + name: 'spring-integration-jms', + namespace: 'org.springframework.integration', + version: '5.5.5', + qualifiers: undefined, + subpath: undefined } ``` @@ -69,7 +72,7 @@ const pkg = new PackageURL( console.log(pkg.toString()) ``` -will log +=> ``` pkg:maven/org.springframework.integration/spring-integration-jms@5.5.5 @@ -85,8 +88,65 @@ try { } ``` -will log +=> ``` Invalid purl: missing required "pkg" scheme component ``` + +#### Helper Objects + +Helpers for encoding, normalizing, and validating purl components and types can +be imported directly from the module or found on the PackageURL class as static +properties. +```js +import { + PackageURL, + PurlComponent, + PurlType +} from 'packageurl-js' + +PurlComponent === PackageURL.Component // => true +PurlType === PackageURL.Type // => true +``` + +#### PurlComponent + +Contains the following properties each with their own `encode`, `normalize`, +and `validate` methods, e.g. `PurlComponent.name.validate(nameStr)`: + - type + - namespace + - name + - version + - qualifiers + - qualifierKey + - qualifierValue + - subpath + +#### PurlType + +Contains the following properties each with their own `normalize`, and `validate` +methods, e.g. `PurlType.npm.validate(purlObj)`: + - alpm + - apk + - bitbucket + - bitnami + - composer + - conan + - cran + - deb + - github + - gitlab + - golang + - hex + - huggingface + - luarocks + - maven + - mlflow + - npm + - oci + - pub + - pypi + - qpkg + - rpm + - swift From ff590d20a64a89152d4bb2cae3ee32a30bb28755 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 14 Aug 2024 16:46:01 -0400 Subject: [PATCH 12/15] feat: encode qualifiers with URLSearchParams --- src/encode.js | 53 ++++++++++++++++++++++++---------- src/purl-component.js | 2 ++ test/data/contrib-tests.json | 16 ++++++++-- test/data/test-suite-data.json | 24 ++++----------- 4 files changed, 59 insertions(+), 36 deletions(-) diff --git a/src/encode.js b/src/encode.js index 68a9995..aef0c61 100644 --- a/src/encode.js +++ b/src/encode.js @@ -1,5 +1,10 @@ 'use strict' +const { isObject } = require('./objects') +const { isNonEmptyString } = require('./strings') + +const reusedSearchParams = new URLSearchParams() + const { encodeURIComponent } = globalThis function encodeWithColonAndForwardSlash(str) { @@ -15,40 +20,54 @@ function encodeWithForwardSlash(str) { } function encodeNamespace(namespace) { - return typeof namespace === 'string' && namespace.length + return isNonEmptyString(namespace) ? encodeWithColonAndForwardSlash(namespace) : '' } function encodeVersion(version) { - return typeof version === 'string' && version.length - ? encodeWithColonAndPlusSign(version) - : '' + return isNonEmptyString(version) ? encodeWithColonAndPlusSign(version) : '' } function encodeQualifiers(qualifiers) { - let query = '' - if (qualifiers !== null && typeof qualifiers === 'object') { + if (isObject(qualifiers)) { // Sort this list of qualifier strings lexicographically. const qualifiersKeys = Object.keys(qualifiers).sort() + const searchParams = new URLSearchParams() for (let i = 0, { length } = qualifiersKeys; i < length; i += 1) { const key = qualifiersKeys[i] - query = `${query}${i === 0 ? '' : '&'}${key}=${encodeQualifierValue(qualifiers[key])}` + searchParams.set(key, qualifiers[key]) } + return replacePlusSignWithPercentEncodedSpace(searchParams.toString()) } - return query + return '' } -function encodeQualifierValue(qualifierValue) { - return typeof qualifierValue === 'string' && qualifierValue.length - ? encodeWithColonAndForwardSlash(qualifierValue) +function encodeQualifierParam(qualifierValue) { + return isNonEmptyString + ? encodeURLSearchParamWithPercentEncodedSpace(param) : '' } function encodeSubpath(subpath) { - return typeof subpath === 'string' && subpath.length - ? encodeWithForwardSlash(subpath) - : '' + return isNonEmptyString(subpath) ? encodeWithForwardSlash(subpath) : '' +} + +function encodeURLSearchParam(param) { + // Param key and value are encoded with `percentEncodeSet` of + // 'application/x-www-form-urlencoded' and `spaceAsPlus` of `true`. + // https://url.spec.whatwg.org/#urlencoded-serializing + reusedSearchParams.set('_', qualifierValue) + return reusedSearchParams.toString().slice(2) +} + +function encodeURLSearchParamWithPercentEncodedSpace(str) { + return replacePlusSignWithPercentEncodedSpace(encodeURLSearchParam(str)) +} + +function replacePlusSignWithPercentEncodedSpace(str) { + // Convert plus signs to %20 for better portability. + return str.replace(/\+/g, '%20') } module.exports = { @@ -58,7 +77,9 @@ module.exports = { encodeNamespace, encodeVersion, encodeQualifiers, - encodeQualifierValue, + encodeQualifierParam, encodeSubpath, - encodeURIComponent + encodeURIComponent, + encodeURLSearchParam, + encodeURLSearchParamWithPercentEncodedSpace } diff --git a/src/purl-component.js b/src/purl-component.js index 5f53017..31c9c07 100644 --- a/src/purl-component.js +++ b/src/purl-component.js @@ -4,6 +4,7 @@ const { encodeNamespace, encodeVersion, encodeQualifiers, + encodeQualifierKey, encodeQualifierValue, encodeSubpath, encodeURIComponent @@ -69,6 +70,7 @@ module.exports = { namespace: encodeNamespace, version: encodeVersion, qualifiers: encodeQualifiers, + qualifierKey: encodeQualifierKey, qualifierValue: encodeQualifierValue, subpath: encodeSubpath }, diff --git a/test/data/contrib-tests.json b/test/data/contrib-tests.json index 59a092f..9d18a8f 100644 --- a/test/data/contrib-tests.json +++ b/test/data/contrib-tests.json @@ -107,6 +107,18 @@ "subpath": null, "is_invalid": false }, + { + "description": "maven requires a namespace", + "purl": "pkg:maven/io@1.3.4", + "canonical_purl": "pkg:maven/io@1.3.4", + "type": "maven", + "namespace": null, + "name": null, + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, { "description": "improperly encoded version string", "purl": "pkg:maven/org.apache.commons/io@1.4.0-$@", @@ -120,8 +132,8 @@ "is_invalid": true }, { - "description": "In namespace, leading and trailing slashes '/' are not significant and should be stripped in the canonical form", - "purl": "pkg:golang//github.com/ll/xlog@v2.0.0", + "description": "leading and trailing slashes '/' are not significant and should be stripped in the canonical form", + "purl": "pkg:golang//github.com///ll////xlog@v2.0.0", "canonical_purl": "pkg:golang/github.com/ll/xlog@v2.0.0", "type": "golang", "namespace": "github.com/ll", diff --git a/test/data/test-suite-data.json b/test/data/test-suite-data.json index 299e6ce..c4872d2 100644 --- a/test/data/test-suite-data.json +++ b/test/data/test-suite-data.json @@ -110,7 +110,7 @@ { "description": "maven often uses qualifiers", "purl": "pkg:Maven/org.apache.xmlgraphics/batik-anim@1.9.1?classifier=sources&repositorY_url=repo.spring.io/release", - "canonical_purl": "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?classifier=sources&repository_url=repo.spring.io/release", + "canonical_purl": "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?classifier=sources&repository_url=repo.spring.io%2Frelease", "type": "maven", "namespace": "org.apache.xmlgraphics", "name": "batik-anim", @@ -122,7 +122,7 @@ { "description": "maven pom reference", "purl": "pkg:Maven/org.apache.xmlgraphics/batik-anim@1.9.1?extension=pom&repositorY_url=repo.spring.io/release", - "canonical_purl": "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?extension=pom&repository_url=repo.spring.io/release", + "canonical_purl": "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?extension=pom&repository_url=repo.spring.io%2Frelease", "type": "maven", "namespace": "org.apache.xmlgraphics", "name": "batik-anim", @@ -143,18 +143,6 @@ "subpath": null, "is_invalid": false }, - { - "description": "maven requires a namespace", - "purl": "pkg:maven/io@1.3.4", - "canonical_purl": "pkg:maven/io@1.3.4", - "type": "maven", - "namespace": null, - "name": null, - "version": null, - "qualifiers": null, - "subpath": null, - "is_invalid": true - }, { "description": "npm can be scoped", "purl": "pkg:npm/%40angular/animation@12.3.1", @@ -494,7 +482,7 @@ { "description": "Hugging Face model with staging endpoint", "purl": "pkg:huggingface/microsoft/deberta-v3-base@559062ad13d311b87b2c455e67dcd5f1c8f65111?repository_url=https://hub-ci.huggingface.co", - "canonical_purl": "pkg:huggingface/microsoft/deberta-v3-base@559062ad13d311b87b2c455e67dcd5f1c8f65111?repository_url=https://hub-ci.huggingface.co", + "canonical_purl": "pkg:huggingface/microsoft/deberta-v3-base@559062ad13d311b87b2c455e67dcd5f1c8f65111?repository_url=https%3A%2F%2Fhub-ci.huggingface.co", "type": "huggingface", "namespace": "microsoft", "name": "deberta-v3-base", @@ -518,7 +506,7 @@ { "description": "MLflow model tracked in Azure Databricks (case insensitive)", "purl": "pkg:mlflow/CreditFraud@3?repository_url=https://adb-5245952564735461.0.azuredatabricks.net/api/2.0/mlflow", - "canonical_purl": "pkg:mlflow/creditfraud@3?repository_url=https://adb-5245952564735461.0.azuredatabricks.net/api/2.0/mlflow", + "canonical_purl": "pkg:mlflow/creditfraud@3?repository_url=https%3A%2F%2Fadb-5245952564735461.0.azuredatabricks.net%2Fapi%2F2.0%2Fmlflow", "type": "mlflow", "namespace": null, "name": "creditfraud", @@ -530,7 +518,7 @@ { "description": "MLflow model tracked in Azure ML (case sensitive)", "purl": "pkg:mlflow/CreditFraud@3?repository_url=https://westus2.api.azureml.ms/mlflow/v1.0/subscriptions/a50f2011-fab8-4164-af23-c62881ef8c95/resourceGroups/TestResourceGroup/providers/Microsoft.MachineLearningServices/workspaces/TestWorkspace", - "canonical_purl": "pkg:mlflow/CreditFraud@3?repository_url=https://westus2.api.azureml.ms/mlflow/v1.0/subscriptions/a50f2011-fab8-4164-af23-c62881ef8c95/resourceGroups/TestResourceGroup/providers/Microsoft.MachineLearningServices/workspaces/TestWorkspace", + "canonical_purl": "pkg:mlflow/CreditFraud@3?repository_url=https%3A%2F%2Fwestus2.api.azureml.ms%2Fmlflow%2Fv1.0%2Fsubscriptions%2Fa50f2011-fab8-4164-af23-c62881ef8c95%2FresourceGroups%2FTestResourceGroup%2Fproviders%2FMicrosoft.MachineLearningServices%2Fworkspaces%2FTestWorkspace", "type": "mlflow", "namespace": null, "name": "CreditFraud", @@ -542,7 +530,7 @@ { "description": "MLflow model with unique identifiers", "purl": "pkg:mlflow/trafficsigns@10?model_uuid=36233173b22f4c89b451f1228d700d49&run_id=410a3121-2709-4f88-98dd-dba0ef056b0a&repository_url=https://adb-5245952564735461.0.azuredatabricks.net/api/2.0/mlflow", - "canonical_purl": "pkg:mlflow/trafficsigns@10?model_uuid=36233173b22f4c89b451f1228d700d49&repository_url=https://adb-5245952564735461.0.azuredatabricks.net/api/2.0/mlflow&run_id=410a3121-2709-4f88-98dd-dba0ef056b0a", + "canonical_purl": "pkg:mlflow/trafficsigns@10?model_uuid=36233173b22f4c89b451f1228d700d49&repository_url=https%3A%2F%2Fadb-5245952564735461.0.azuredatabricks.net%2Fapi%2F2.0%2Fmlflow&run_id=410a3121-2709-4f88-98dd-dba0ef056b0a", "type": "mlflow", "namespace": null, "name": "trafficsigns", From f81a6be5ef1ffd74ca0a3fc73a684beaec7e5c63 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 14 Aug 2024 17:35:51 -0400 Subject: [PATCH 13/15] fix: use encodeQualifierValue for qualifierKey and qualifierValue --- src/purl-component.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/purl-component.js b/src/purl-component.js index 31c9c07..e61dffd 100644 --- a/src/purl-component.js +++ b/src/purl-component.js @@ -4,8 +4,7 @@ const { encodeNamespace, encodeVersion, encodeQualifiers, - encodeQualifierKey, - encodeQualifierValue, + encodeQualifierParam, encodeSubpath, encodeURIComponent } = require('./encode') @@ -70,8 +69,8 @@ module.exports = { namespace: encodeNamespace, version: encodeVersion, qualifiers: encodeQualifiers, - qualifierKey: encodeQualifierKey, - qualifierValue: encodeQualifierValue, + qualifierKey: encodeQualifierParam, + qualifierValue: encodeQualifierParam, subpath: encodeSubpath }, normalize: { From 96822afa27a446be58efad27bcf21f6c27585397 Mon Sep 17 00:00:00 2001 From: jdalton Date: Thu, 15 Aug 2024 15:02:34 -0400 Subject: [PATCH 14/15] fix: correct param name typos --- src/encode.js | 62 +++++++++++++++++-------------------------- src/purl-component.js | 20 +++++++------- src/validate.js | 3 ++- 3 files changed, 36 insertions(+), 49 deletions(-) diff --git a/src/encode.js b/src/encode.js index aef0c61..17c4f4e 100644 --- a/src/encode.js +++ b/src/encode.js @@ -4,29 +4,30 @@ const { isObject } = require('./objects') const { isNonEmptyString } = require('./strings') const reusedSearchParams = new URLSearchParams() +const reusedSearchParamKey = '_' +const reusedSearchParamOffset = 2 // '_='.length const { encodeURIComponent } = globalThis -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 encodeNamespace(namespace) { return isNonEmptyString(namespace) - ? encodeWithColonAndForwardSlash(namespace) + ? encodeURIComponent(namespace) + .replace(/%3A/g, ':') + .replace(/%2F/g, '/') : '' } -function encodeVersion(version) { - return isNonEmptyString(version) ? encodeWithColonAndPlusSign(version) : '' +function encodeQualifierParam(param) { + if (isNonEmptyString(param)) { + // Param key and value are encoded with `percentEncodeSet` of + // 'application/x-www-form-urlencoded' and `spaceAsPlus` of `true`. + // https://url.spec.whatwg.org/#urlencoded-serializing + reusedSearchParams.set(reusedSearchParamKey, param) + return replacePlusSignWithPercentEncodedSpace( + reusedSearchParams.toString().slice(reusedSearchParamOffset) + ) + } + return '' } function encodeQualifiers(qualifiers) { @@ -43,26 +44,16 @@ function encodeQualifiers(qualifiers) { return '' } -function encodeQualifierParam(qualifierValue) { - return isNonEmptyString - ? encodeURLSearchParamWithPercentEncodedSpace(param) - : '' -} - function encodeSubpath(subpath) { - return isNonEmptyString(subpath) ? encodeWithForwardSlash(subpath) : '' -} - -function encodeURLSearchParam(param) { - // Param key and value are encoded with `percentEncodeSet` of - // 'application/x-www-form-urlencoded' and `spaceAsPlus` of `true`. - // https://url.spec.whatwg.org/#urlencoded-serializing - reusedSearchParams.set('_', qualifierValue) - return reusedSearchParams.toString().slice(2) + return isNonEmptyString(subpath) + ? encodeURIComponent(subpath).replace(/%2F/g, '/') + : '' } -function encodeURLSearchParamWithPercentEncodedSpace(str) { - return replacePlusSignWithPercentEncodedSpace(encodeURLSearchParam(str)) +function encodeVersion(version) { + return isNonEmptyString(version) + ? encodeURIComponent(version).replace(/%3A/g, ':').replace(/%2B/g, '+') + : '' } function replacePlusSignWithPercentEncodedSpace(str) { @@ -71,15 +62,10 @@ function replacePlusSignWithPercentEncodedSpace(str) { } module.exports = { - encodeWithColonAndForwardSlash, - encodeWithColonAndPlusSign, - encodeWithForwardSlash, encodeNamespace, encodeVersion, encodeQualifiers, encodeQualifierParam, encodeSubpath, - encodeURIComponent, - encodeURLSearchParam, - encodeURLSearchParamWithPercentEncodedSpace + encodeURIComponent } diff --git a/src/purl-component.js b/src/purl-component.js index e61dffd..db76f15 100644 --- a/src/purl-component.js +++ b/src/purl-component.js @@ -12,28 +12,28 @@ const { const { createHelpersNamespaceObject } = require('./helpers') const { - normalizeName, + normalizeType, normalizeNamespace, + normalizeName, + normalizeVersion, normalizeQualifiers, - normalizeSubpath, - normalizeType, - normalizeVersion + normalizeSubpath } = require('./normalize') -const { localeCompare } = require('./strings') +const { localeCompare, isNonEmptyString } = require('./strings') const { - validateName, + validateType, validateNamespace, + validateName, + validateVersion, validateQualifiers, validateQualifierKey, - validateSubpath, - validateType, - validateVersion + validateSubpath } = require('./validate') const PurlComponentEncoder = (comp) => - typeof comp === 'string' && comp.length ? encodeURIComponent(comp) : '' + isNonEmptyString(comp) ? encodeURIComponent(comp) : '' const PurlComponentStringNormalizer = (comp) => typeof comp === 'string' ? comp : undefined diff --git a/src/validate.js b/src/validate.js index 42a41cc..70dff28 100644 --- a/src/validate.js +++ b/src/validate.js @@ -1,6 +1,7 @@ 'use strict' const { isNullishOrEmptyString } = require('./lang') +const { isNonEmptyString } = require('./strings') function validateEmptyByType(type, name, value, throws) { if (!isNullishOrEmptyString(value)) { @@ -104,7 +105,7 @@ function validateRequiredByType(type, name, value, throws) { } function validateStartsWithoutNumber(name, value, throws) { - if (value.length !== 0) { + if (isNonEmptyString(value)) { const code = value.charCodeAt(0) if (code >= 48 /*'0'*/ && code <= 57 /*'9'*/) { if (throws) { From b6c8ce8abe592f327154b5733cd9aad350337cc3 Mon Sep 17 00:00:00 2001 From: jdalton Date: Thu, 15 Aug 2024 15:21:44 -0400 Subject: [PATCH 15/15] fix: correct package-url.d.ts readonly type casing --- src/package-url.d.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/package-url.d.ts b/src/package-url.d.ts index 123a531..5d95936 100644 --- a/src/package-url.d.ts +++ b/src/package-url.d.ts @@ -36,13 +36,13 @@ declare module "packageurl-js" { export type PurlTypeValidator = (purl: PackageURL, throws: boolean) => boolean - export type PurlComponentEntry = ReadOnly<{ + export type PurlComponentEntry = Readonly<{ encode: PurlComponentEncoder normalize: PurlComponentStringNormalizer validate: PurlComponentValidator }> - export type PurlTypeEntry = ReadOnly<{ + export type PurlTypeEntry = Readonly<{ normalize: PurlTypNormalizer validate: PurlTypeValidator }> @@ -51,7 +51,7 @@ declare module "packageurl-js" { * Collection of PURL component encode, normalize, and validate methods. * @see {@link https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#rules-for-each-purl-component specification} */ - export type PurlComponent = ReadOnly<{ + export type PurlComponent = Readonly<{ type: PurlComponentEntry namespace: PurlComponentEntry name: PurlComponentEntry @@ -66,7 +66,7 @@ declare module "packageurl-js" { * Known qualifiers names. * @see {@link https://github.com/package-url/purl-spec/blob/master/PURL-SPECIFICATION.rst#known-qualifiers-keyvalue-pairs specification} */ - export type PurlQualifierNames = ReadOnly<{ + export type PurlQualifierNames = Readonly<{ RepositoryUrl:'repository_url', DownloadUrl: 'download_url', VcsUrl: 'vcs_url', @@ -78,7 +78,7 @@ declare module "packageurl-js" { * Collection of PURL type normalize and validate methods. * @see {@link https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#known-purl-types specification} */ - export type PurlType = ReadOnly<{ + export type PurlType = Readonly<{ alpm: PurlTypeEntry apk: PurlTypeEntry bitbucket: PurlTypeEntry @@ -184,9 +184,10 @@ declare module "packageurl-js" { ] } + // @ts-ignore export const PurlComponent = {} - + // @ts-ignore export const PurlQualifierNames = {} - + // @ts-ignore export const PurlType = {} }