fix(npm): decode URL-encoded package names after regex extraction

Scoped npm packages use %2f encoding for the slash in URLs (e.g. @scope%2fpackage).
Previously, the encoded name was used as-is for storage and packument metadata,
causing npm install to fail with EINVALIDPACKAGENAME. Now each regex extraction
point decodes the package name via decodeURIComponent while keeping the path
encoded for correct regex matching.
This commit is contained in:
2026-03-21 11:30:06 +00:00
parent 9bbc3da484
commit 37e4c5be4a

View File

@@ -155,45 +155,45 @@ export class NpmRegistry extends BaseRegistry {
// Dist-tags: /-/package/{package}/dist-tags // Dist-tags: /-/package/{package}/dist-tags
const distTagsMatch = path.match(/^\/-\/package\/(@?[^\/]+(?:\/[^\/]+)?)\/dist-tags(?:\/(.+))?$/); const distTagsMatch = path.match(/^\/-\/package\/(@?[^\/]+(?:\/[^\/]+)?)\/dist-tags(?:\/(.+))?$/);
if (distTagsMatch) { if (distTagsMatch) {
const [, packageName, tag] = distTagsMatch; const [, rawPkgName, tag] = distTagsMatch;
return this.handleDistTags(context.method, packageName, tag, context.body, token); return this.handleDistTags(context.method, decodeURIComponent(rawPkgName), tag, context.body, token);
} }
// Tarball download: /{package}/-/{filename}.tgz // Tarball download: /{package}/-/{filename}.tgz
const tarballMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/(.+\.tgz)$/); const tarballMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/(.+\.tgz)$/);
if (tarballMatch) { if (tarballMatch) {
const [, packageName, filename] = tarballMatch; const [, rawPkgName, filename] = tarballMatch;
return this.handleTarballDownload(packageName, filename, token, actor); return this.handleTarballDownload(decodeURIComponent(rawPkgName), filename, token, actor);
} }
// Unpublish specific version: DELETE /{package}/-/{version} // Unpublish specific version: DELETE /{package}/-/{version}
const unpublishVersionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/([^\/]+)$/); const unpublishVersionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/([^\/]+)$/);
if (unpublishVersionMatch && context.method === 'DELETE') { if (unpublishVersionMatch && context.method === 'DELETE') {
const [, packageName, version] = unpublishVersionMatch; const [, rawPkgName, version] = unpublishVersionMatch;
this.logger.log('debug', 'unpublishVersionMatch', { packageName, version }); this.logger.log('debug', 'unpublishVersionMatch', { packageName: decodeURIComponent(rawPkgName), version });
return this.unpublishVersion(packageName, version, token); return this.unpublishVersion(decodeURIComponent(rawPkgName), version, token);
} }
// Unpublish entire package: DELETE /{package}/-rev/{rev} // Unpublish entire package: DELETE /{package}/-rev/{rev}
const unpublishPackageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-rev\/([^\/]+)$/); const unpublishPackageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-rev\/([^\/]+)$/);
if (unpublishPackageMatch && context.method === 'DELETE') { if (unpublishPackageMatch && context.method === 'DELETE') {
const [, packageName, rev] = unpublishPackageMatch; const [, rawPkgName, rev] = unpublishPackageMatch;
this.logger.log('debug', 'unpublishPackageMatch', { packageName, rev }); this.logger.log('debug', 'unpublishPackageMatch', { packageName: decodeURIComponent(rawPkgName), rev });
return this.unpublishPackage(packageName, token); return this.unpublishPackage(decodeURIComponent(rawPkgName), token);
} }
// Package version: /{package}/{version} // Package version: /{package}/{version}
const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/); const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/);
if (versionMatch) { if (versionMatch) {
const [, packageName, version] = versionMatch; const [, rawPkgName, version] = versionMatch;
this.logger.log('debug', 'versionMatch', { packageName, version }); this.logger.log('debug', 'versionMatch', { packageName: decodeURIComponent(rawPkgName), version });
return this.handlePackageVersion(packageName, version, token, actor); return this.handlePackageVersion(decodeURIComponent(rawPkgName), version, token, actor);
} }
// Package operations: /{package} // Package operations: /{package}
const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/); const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/);
if (packageMatch) { if (packageMatch) {
const packageName = packageMatch[1]; const packageName = decodeURIComponent(packageMatch[1]);
this.logger.log('debug', 'packageMatch', { packageName }); this.logger.log('debug', 'packageMatch', { packageName });
return this.handlePackage(context.method, packageName, context.body, context.query, token, actor); return this.handlePackage(context.method, packageName, context.body, context.query, token, actor);
} }