From 39e42665b3426cf856171073e8a90f59840a22cb Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Fri, 8 May 2026 22:29:27 +0200 Subject: [PATCH] fix(git-resolver): avoid encoded slash in GitLab tarball URL (#11551) * fix(git-resolver): avoid encoded slash in GitLab tarball URL hosted-git-info's default GitLab tarball URL routes through `/api/v4/projects/%2F/...`. The `%2F` survives into the virtual store directory name (depPathToFilename only escapes raw `/`, not `%`), and Node refuses to import any module whose path contains an encoded slash. The same URL is also intermittently rejected by GitLab with a 406. Override the GitLab tarballtemplate to the `/-/archive/` URL, which works for both public and private repos and contains no encoded slashes. Closes #11533 * test: avoid cspell-flagged words * test: keep existing gitlab assertions, only add new ones Restore the skipped tests' original API-URL assertions; they document the old expected shape and weren't running anyway. Add the new `/-/archive/` URL to the pick-fetcher fixture as an additional case so both shapes are exercised. --- .changeset/gitlab-tarball-archive-url.md | 6 ++++++ cspell.json | 1 + fetching/pick-fetcher/test/pickFetcher.ts | 1 + .../git-resolver/src/parseBareSpecifier.ts | 9 ++++++++ resolving/git-resolver/test/index.ts | 21 +++++++++++++++++++ 5 files changed, 38 insertions(+) create mode 100644 .changeset/gitlab-tarball-archive-url.md diff --git a/.changeset/gitlab-tarball-archive-url.md b/.changeset/gitlab-tarball-archive-url.md new file mode 100644 index 0000000000..87d4a8d669 --- /dev/null +++ b/.changeset/gitlab-tarball-archive-url.md @@ -0,0 +1,6 @@ +--- +"@pnpm/resolving.git-resolver": patch +"pnpm": patch +--- + +Fixed installation of GitLab-hosted dependencies. pnpm now downloads the tarball from `https://gitlab.com///-/archive//-.tar.gz` instead of the GitLab API endpoint that contained an encoded slash (`%2F`) between user and project. The encoded slash both triggered `406 Not Acceptable` responses from GitLab and produced virtual store directory names that Node refused to import (`ERR_INVALID_MODULE_SPECIFIER`) [#11533](https://github.com/pnpm/pnpm/issues/11533). diff --git a/cspell.json b/cspell.json index ecf753a62a..5af2c84ae7 100644 --- a/cspell.json +++ b/cspell.json @@ -330,6 +330,7 @@ "szia", "tabtab", "taffydb", + "tarballtemplate", "teambit", "tempy", "testcase", diff --git a/fetching/pick-fetcher/test/pickFetcher.ts b/fetching/pick-fetcher/test/pickFetcher.ts index fbadbc3768..b33f3d0ee5 100644 --- a/fetching/pick-fetcher/test/pickFetcher.ts +++ b/fetching/pick-fetcher/test/pickFetcher.ts @@ -33,6 +33,7 @@ test.each([ 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933', 'https://bitbucket.org/pnpmjs/git-resolver/get/87cf6a67064d2ce56e8cd20624769a5512b83ff9.tar.gz', 'https://gitlab.com/api/v4/projects/pnpm%2Fgit-resolver/repository/archive.tar.gz', + 'https://gitlab.com/pnpm/git-resolver/-/archive/988c61e11dc8d9ca0b5580cb15291951812549dc/git-resolver-988c61e11dc8d9ca0b5580cb15291951812549dc.tar.gz', ])('should pick gitHostedTarball fetcher', async (tarball) => { const gitHostedTarball = jest.fn() as FetchFunction const fetcher = await pickFetcher(createMockFetchers({ gitHostedTarball }), { tarball }) diff --git a/resolving/git-resolver/src/parseBareSpecifier.ts b/resolving/git-resolver/src/parseBareSpecifier.ts index 1509f8c9d0..facfa1bdf1 100644 --- a/resolving/git-resolver/src/parseBareSpecifier.ts +++ b/resolving/git-resolver/src/parseBareSpecifier.ts @@ -122,6 +122,7 @@ async function fromHostedGit (hosted: any, dispatcherOptions: DispatcherOptions) fetchSpec: fetchSpec!, hosted: { ...hosted, + tarballtemplate: hosted.type === 'gitlab' ? gitlabTarballTemplate : hosted.tarballtemplate, _fill: hosted._fill, tarball: hosted.tarball, }, @@ -130,6 +131,14 @@ async function fromHostedGit (hosted: any, dispatcherOptions: DispatcherOptions) } } +// hosted-git-info's default GitLab tarball URL contains an encoded slash +// (`%2F`) which survives into the virtual store directory name and makes +// Node refuse to import the package (ERR_INVALID_MODULE_SPECIFIER). +function gitlabTarballTemplate ({ domain, user, project, committish }: { domain: string, user: string, project: string, committish: string | null }): string { + const ref = committish ? encodeURIComponent(committish) : 'HEAD' + return `https://${domain}/${user}/${project}/-/archive/${ref}/${project}-${ref}.tar.gz` +} + async function isRepoPublic (httpsUrl: string, dispatcherOptions: DispatcherOptions): Promise { try { const response = await fetchWithDispatcher(httpsUrl.replace(/\.git$/, ''), { method: 'HEAD', redirect: 'manual', retry: { retries: 0 }, dispatcherOptions }) diff --git a/resolving/git-resolver/test/index.ts b/resolving/git-resolver/test/index.ts index cf8c8dc3ba..8e9ab716a1 100644 --- a/resolving/git-resolver/test/index.ts +++ b/resolving/git-resolver/test/index.ts @@ -370,6 +370,27 @@ test('resolveFromGit() gitlab with colon in the URL', async () => { }) }) +// Regression test for #11533: the tarball URL must not contain `%2F`, +// otherwise GitLab returns 406 and Node refuses to import the package +// (the encoded slash ends up in the virtual store directory name). +test('resolveFromGit() gitlab tarball uses /-/archive/ URL without encoded slash', async () => { + const headCommit = '988c61e11dc8d9ca0b5580cb15291951812549dc' + jest.mocked(fetchWithDispatcher).mockImplementation(async (_url, _opts) => { + return { ok: true } as any // eslint-disable-line @typescript-eslint/no-explicit-any + }) + jest.mocked(git).mockImplementation(async () => ({ stdout: `${headCommit}\tHEAD` })) + const resolveResult = await resolveFromGit({ bareSpecifier: 'https://gitlab.com/pnpmjs/git-resolver' }) + expect(resolveResult).toStrictEqual({ + id: `https://gitlab.com/pnpmjs/git-resolver/-/archive/${headCommit}/git-resolver-${headCommit}.tar.gz`, + normalizedBareSpecifier: 'gitlab:pnpmjs/git-resolver', + resolution: { + tarball: `https://gitlab.com/pnpmjs/git-resolver/-/archive/${headCommit}/git-resolver-${headCommit}.tar.gz`, + gitHosted: true, + }, + resolvedVia: 'git-repository', + }) +}) + // This test stopped working. Probably an environmental issue. test.skip('resolveFromGit() gitlab with commit', async () => { const resolveResult = await resolveFromGit({ bareSpecifier: 'gitlab:pnpm/git-resolver#988c61e11dc8d9ca0b5580cb15291951812549dc' })