fix: pin integrity of git-hosted tarballs in lockfile (#11491)

Cherry-pick of #11481 from main, adapted to the v10 layout.

For git-hosted tarballs (codeload.github.com / gitlab.com / bitbucket.org)
the fetcher dropped the integrity it computed while downloading, so the
lockfile only stored the URL. A compromised git host or man-in-the-middle
could serve a substituted tarball on subsequent installs and pnpm would
install it without lockfile changes.

This pins the SHA-512 SRI of the raw tarball in the lockfile in the same
sha512-<base64> form npm-registry tarballs use; subsequent installs verify
the download against that integrity in the worker.

A new optional gitHosted: boolean field is recorded on TarballResolution
so every store-key consumer can route by a single typed read instead of
re-deriving the routing from the URL. Lockfiles written by older pnpm
versions are enriched on load (URL fallback) so the field can be relied
on uniformly.

🤖 Cherry-picked by Claude (claude-opus-4-7) on behalf of @zkochan
This commit is contained in:
Zoltan Kochan
2026-05-06 14:28:42 +02:00
committed by GitHub
parent c39f677b6e
commit edbe2a7c9b
19 changed files with 222 additions and 44 deletions

View File

@@ -0,0 +1,19 @@
---
"@pnpm/git-resolver": patch
"@pnpm/license-scanner": patch
"@pnpm/lockfile.fs": patch
"@pnpm/lockfile.types": patch
"@pnpm/lockfile.utils": patch
"@pnpm/package-requester": patch
"@pnpm/pick-fetcher": patch
"@pnpm/plugin-commands-rebuild": patch
"@pnpm/plugin-commands-store": patch
"@pnpm/resolve-dependencies": patch
"@pnpm/resolver-base": patch
"@pnpm/tarball-fetcher": patch
"pnpm": patch
---
Pin the integrity of git-hosted tarballs (codeload.github.com, gitlab.com, bitbucket.org) in the lockfile so that subsequent installs detect a tampered or substituted tarball and refuse to install it. Previously the lockfile only stored the tarball URL for git dependencies, so a compromised git host or a man-in-the-middle could serve arbitrary code on later installs without lockfile changes.
A new `gitHosted: true` field is recorded on git-hosted tarball resolutions in the lockfile, letting every reader/writer route them by a single typed check instead of pattern-matching the tarball URL in each call site. Lockfiles written by older pnpm versions are enriched on load (URL fallback) so the field can be relied on uniformly across the codebase.

View File

@@ -351,9 +351,12 @@ async function _rebuild (
}
const resolution = (pkgSnapshot.resolution as TarballResolution)
let sideEffectsCacheKey: string | undefined
const pkgId = `${pkgInfo.name}@${pkgInfo.version}`
if (opts.skipIfHasSideEffectsCache && resolution.integrity) {
const filesIndexFile = getIndexFilePathInCafs(opts.storeDir, resolution.integrity!.toString(), pkgId)
// Match the resolver-supplied pkg.id used by package-requester: the
// tarball URL for git-hosted packages (nonSemverVersion) and
// `name@version` for npm-hosted ones.
const pkgId = pkgInfo.nonSemverVersion ?? `${pkgInfo.name}@${pkgInfo.version}`
if (opts.skipIfHasSideEffectsCache && (resolution.gitHosted === true || resolution.integrity)) {
const filesIndexFile = pickRebuildIndexFilePath(opts.storeDir, resolution, pkgId, opts.virtualStoreDirMaxLength)
let pkgFilesIndex: PackageFilesIndex | undefined
try {
pkgFilesIndex = await loadJsonFile<PackageFilesIndex>(filesIndexFile)
@@ -388,9 +391,9 @@ async function _rebuild (
shellEmulator: opts.shellEmulator,
unsafePerm: opts.unsafePerm || false,
})
if (hasSideEffects && (opts.sideEffectsCacheWrite ?? true) && resolution.integrity) {
if (hasSideEffects && (opts.sideEffectsCacheWrite ?? true) && (resolution.gitHosted === true || resolution.integrity)) {
builtDepPaths.add(depPath)
const filesIndexFile = getIndexFilePathInCafs(opts.storeDir, resolution.integrity!.toString(), pkgId)
const filesIndexFile = pickRebuildIndexFilePath(opts.storeDir, resolution, pkgId, opts.virtualStoreDirMaxLength)
try {
if (!sideEffectsCacheKey) {
sideEffectsCacheKey = calcDepState(depGraph, depsStateCache, depPath, {
@@ -471,6 +474,22 @@ async function _rebuild (
return { pkgsThatWereRebuilt, ignoredPkgs }
}
// Git-hosted resolutions are post-processed (preparePackage / packlist) on
// extraction, so their index file lives in the per-package store path
// keyed by built/not-built — not in the integrity-based cafs key, even
// though integrity is now pinned in the lockfile for tamper detection.
function pickRebuildIndexFilePath (
storeDir: string,
resolution: TarballResolution,
pkgId: string,
virtualStoreDirMaxLength: number
): string {
if (resolution.gitHosted === true) {
return path.join(storeDir, dp.depPathToFilename(pkgId, virtualStoreDirMaxLength), 'integrity.json')
}
return getIndexFilePathInCafs(storeDir, resolution.integrity!.toString(), pkgId)
}
function binDirsInAllParentDirs (pkgRoot: string, lockfileDir: string): string[] {
const binDirs: string[] = []
let dir = pkgRoot

View File

@@ -7,7 +7,12 @@ export function pickFetcher (fetcherByHostingType: Partial<Fetchers>, resolution
if (resolution.type == null) {
if (resolution.tarball.startsWith('file:')) {
fetcherType = 'localTarball'
} else if (isGitHostedPkgUrl(resolution.tarball)) {
} else if (
('gitHosted' in resolution && resolution.gitHosted === true) ||
// URL fallback for resolutions that didn't go through the resolver or
// the lockfile loader (e.g., constructed ad-hoc).
isGitHostedPkgUrl(resolution.tarball)
) {
fetcherType = 'gitHostedTarball'
} else {
fetcherType = 'remoteTarball'

View File

@@ -27,7 +27,7 @@ export interface CreateGitHostedTarballFetcher {
export function createGitHostedTarballFetcher (fetchRemoteTarball: FetchFunction, fetcherOpts: CreateGitHostedTarballFetcher): FetchFunction {
const fetch = async (cafs: Cafs, resolution: Resolution, opts: FetchOptions) => {
const tempIndexFile = pathTemp(opts.filesIndexFile)
const { filesIndex, manifest, requiresBuild } = await fetchRemoteTarball(cafs, resolution, {
const { filesIndex, manifest, requiresBuild, integrity } = await fetchRemoteTarball(cafs, resolution, {
...opts,
filesIndexFile: tempIndexFile,
})
@@ -40,6 +40,9 @@ export function createGitHostedTarballFetcher (fetchRemoteTarball: FetchFunction
filesIndex: prepareResult.filesIndex,
manifest: prepareResult.manifest ?? manifest,
requiresBuild,
// Propagate the raw tarball integrity so the lockfile pins it and
// future installs detect a tampered tarball from the git host.
integrity,
}
} catch (err: unknown) {
assert(util.types.isNativeError(err))

View File

@@ -468,6 +468,26 @@ test('take only the files included in the package, when fetching a git-hosted pa
'dist/index.js',
'package.json',
])
// The fetcher must surface the integrity of the downloaded git tarball so
// that the lockfile can pin it (CVE: malicious codeload.github.com responses).
expect(result.integrity).toMatch(/^sha512-/)
})
test('verify integrity of git-hosted tarball against the resolution', async () => {
process.chdir(tempy.directory())
const resolution = {
tarball: 'https://codeload.github.com/pnpm-e2e/pkg-with-ignored-files/tar.gz/958d6d487217512bb154d02836e9b5b922a600d8',
// A well-formed sha512 SRI that doesn't match the actual tarball — should
// surface as TarballIntegrityError when the buffer is verified.
integrity: 'sha512-MRqvs50psUtGELoeBcJwDUi7lT6RUXBzTHsU3U701V/DIouBQSZo+tx5xSXDJLEcItepyZPjIncx8Xy4qPFlKw==',
}
await expect(fetch.gitHostedTarball(cafs, resolution, {
filesIndexFile,
lockfileDir: process.cwd(),
pkg,
})).rejects.toThrow(TarballIntegrityError)
})
test('fail when extracting a broken tarball', async () => {

View File

@@ -9,6 +9,7 @@ import {
type LockfileFileProjectResolvedDependencies,
type LockfilePackageInfo,
type PackageSnapshots,
type TarballResolution,
} from '@pnpm/lockfile.types'
import { type DepPath, DEPENDENCIES_FIELDS } from '@pnpm/types'
import isEmpty from 'ramda/src/isEmpty'
@@ -18,6 +19,19 @@ import pickBy from 'ramda/src/pickBy'
import pick from 'ramda/src/pick'
import { LOCKFILE_VERSION } from '@pnpm/constants'
// Minimal duplicate of `isGitHostedPkgUrl` from `@pnpm/fetching.pick-fetcher`,
// inlined to avoid pulling the fetcher dep into the lockfile I/O layer. Used
// to enrich entries written by older pnpm versions (which didn't record the
// `gitHosted` field on TarballResolution) so every downstream reader can rely
// on the field directly.
function isGitHostedTarballUrl (url: string): boolean {
return (
url.startsWith('https://codeload.github.com/') ||
url.startsWith('https://bitbucket.org/') ||
url.startsWith('https://gitlab.com/')
) && url.includes('tar.gz')
}
export function convertToLockfileFile (lockfile: LockfileObject): LockfileFile {
const packages: Record<string, LockfilePackageInfo> = {}
const snapshots: Record<string, LockfilePackageSnapshot> = {}
@@ -151,6 +165,7 @@ export function convertToLockfileObject (lockfile: LockfileFile): LockfileObject
for (const [depPath, pkg] of Object.entries(lockfile.snapshots ?? {})) {
const pkgId = removeSuffix(depPath)
packages[depPath as DepPath] = Object.assign(pkg, lockfile.packages?.[pkgId])
enrichGitHostedFlag(packages[depPath as DepPath]?.resolution as TarballResolution | undefined)
}
return {
...omit(['snapshots'], rest),
@@ -159,6 +174,18 @@ export function convertToLockfileObject (lockfile: LockfileFile): LockfileObject
}
}
// Backfill the `gitHosted` flag for tarball resolutions written by older
// pnpm versions. Doing it once at load time lets every downstream reader
// rely on the typed field instead of repeating URL prefix matches.
function enrichGitHostedFlag (resolution: TarballResolution | undefined): void {
if (resolution == null) return
if (resolution.type !== undefined) return
if (resolution.gitHosted != null) return
if (resolution.tarball != null && isGitHostedTarballUrl(resolution.tarball)) {
resolution.gitHosted = true
}
}
function convertProjectSnapshotToInlineSpecifiersFormat (
projectSnapshot: ProjectSnapshot
): LockfileFileProjectSnapshot {

View File

@@ -91,6 +91,19 @@ export interface TarballResolution {
tarball: string
integrity?: string
path?: string
/**
* True for tarballs sourced from a git host (codeload.github.com /
* gitlab.com / bitbucket.org). Such tarballs need preparation
* (preparePackage / packlist) on extraction, and their cached content
* depends on whether build scripts ran, so they're addressed by
* gitHostedStoreIndexKey rather than the integrity-based key.
*
* The git resolver sets this when it produces the resolution; the
* lockfile loader sets it on entries whose URL matches a known git host
* for backward compatibility with lockfiles written before this field
* existed.
*/
gitHosted?: boolean
}
/**

View File

@@ -35,7 +35,6 @@
"dependencies": {
"@pnpm/dependency-path": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/pick-fetcher": "workspace:*",
"@pnpm/resolver-base": "workspace:*",
"@pnpm/types": "workspace:*",
"get-npm-tarball-url": "catalog:",

View File

@@ -3,7 +3,6 @@ import { type PackageSnapshot, type TarballResolution } from '@pnpm/lockfile.typ
import { type Resolution } from '@pnpm/resolver-base'
import { type Registries } from '@pnpm/types'
import getNpmTarballUrl from 'get-npm-tarball-url'
import { isGitHostedPkgUrl } from '@pnpm/pick-fetcher'
import { nameVerFromPkgSnapshot } from './nameVerFromPkgSnapshot.js'
export function pkgSnapshotToResolution (
@@ -14,7 +13,7 @@ export function pkgSnapshotToResolution (
if (
Boolean((pkgSnapshot.resolution as TarballResolution).type) ||
(pkgSnapshot.resolution as TarballResolution).tarball?.startsWith('file:') ||
isGitHostedPkgUrl((pkgSnapshot.resolution as TarballResolution).tarball ?? '')
(pkgSnapshot.resolution as TarballResolution).gitHosted === true
) {
return pkgSnapshot.resolution as Resolution
}

View File

@@ -9,9 +9,6 @@
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../fetching/pick-fetcher"
},
{
"path": "../../packages/dependency-path"
},

View File

@@ -255,7 +255,11 @@ test('re-adding a git repo with a different tag', async () => {
expect(lockfile.packages).toEqual(
{
'is-negative@https://codeload.github.com/kevva/is-negative/tar.gz/163360a8d3ae6bee9524541043197ff356f8ed99': {
resolution: { tarball: 'https://codeload.github.com/kevva/is-negative/tar.gz/163360a8d3ae6bee9524541043197ff356f8ed99' },
resolution: {
tarball: 'https://codeload.github.com/kevva/is-negative/tar.gz/163360a8d3ae6bee9524541043197ff356f8ed99',
integrity: expect.stringMatching(/^sha512-/),
gitHosted: true,
},
version: '1.0.0',
engines: { node: '>=0.10.0' },
},
@@ -272,7 +276,11 @@ test('re-adding a git repo with a different tag', async () => {
expect(lockfile.packages).toEqual(
{
'is-negative@https://codeload.github.com/kevva/is-negative/tar.gz/f7dec4d66a5a56719e49b9f94a24d73f924ddeb3': {
resolution: { tarball: 'https://codeload.github.com/kevva/is-negative/tar.gz/f7dec4d66a5a56719e49b9f94a24d73f924ddeb3' },
resolution: {
tarball: 'https://codeload.github.com/kevva/is-negative/tar.gz/f7dec4d66a5a56719e49b9f94a24d73f924ddeb3',
integrity: expect.stringMatching(/^sha512-/),
gitHosted: true,
},
version: '1.0.1',
engines: { node: '>=0.10.0' },
},

View File

@@ -401,25 +401,27 @@ function getFilesIndexFilePath (
): GetFilesIndexFilePathResult {
const targetRelative = depPathToFilename(opts.pkg.id, ctx.virtualStoreDirMaxLength)
const target = path.join(ctx.storeDir, targetRelative)
if ((opts.pkg.resolution as TarballResolution).integrity) {
return {
target,
filesIndexFile: ctx.getIndexFilePathInCafs((opts.pkg.resolution as TarballResolution).integrity!, opts.pkg.id),
resolution: opts.pkg.resolution as AtomicResolution,
}
}
let resolution!: AtomicResolution
let resolution: AtomicResolution
if (opts.pkg.resolution.type === 'variations') {
resolution = findResolution(opts.pkg.resolution.variants, opts.supportedArchitectures)
if ((resolution as TarballResolution).integrity) {
return {
target,
filesIndexFile: ctx.getIndexFilePathInCafs((resolution as TarballResolution).integrity!, opts.pkg.id),
resolution,
}
}
} else {
resolution = opts.pkg.resolution
resolution = opts.pkg.resolution as AtomicResolution
}
// Git-hosted tarballs are post-processed (preparePackage / packlist) on
// extraction, and their cached content depends on whether build scripts
// ran, so they must stay on the per-package store path keyed by built/
// not-built — never on the integrity-based cafs key, even though
// integrity is now pinned in the lockfile for tamper detection.
if ((resolution as TarballResolution).gitHosted === true) {
const filesIndexFile = path.join(target, opts.ignoreScripts ? 'integrity-not-built.json' : 'integrity.json')
return { filesIndexFile, target, resolution }
}
if ((resolution as TarballResolution).integrity) {
return {
target,
filesIndexFile: ctx.getIndexFilePathInCafs((resolution as TarballResolution).integrity!, opts.pkg.id),
resolution,
}
}
const filesIndexFile = path.join(target, opts.ignoreScripts ? 'integrity-not-built.json' : 'integrity.json')
return { filesIndexFile, target, resolution }

View File

@@ -5,7 +5,7 @@ import {
type PackageSnapshot,
pruneSharedLockfile,
} from '@pnpm/lockfile.pruner'
import { type Resolution } from '@pnpm/resolver-base'
import { type Resolution, type TarballResolution } from '@pnpm/resolver-base'
import { type DepPath, type Registries } from '@pnpm/types'
import * as dp from '@pnpm/dependency-path'
import getNpmTarballUrl from 'get-npm-tarball-url'
@@ -189,11 +189,27 @@ function toLockfileResolution (
if (resolution.type !== undefined || !resolution['integrity']) {
return resolution as LockfileResolution
}
const tarball = resolution['tarball'] as string | undefined
// Honor the resolver-supplied flag, with a URL fallback for resolutions
// that didn't go through the git resolver (e.g. legacy lockfiles read by
// callers that don't enrich the field).
const gitHosted = (resolution as TarballResolution).gitHosted === true ||
(tarball != null && isGitHostedTarballUrl(tarball))
if (lockfileIncludeTarballUrl) {
return {
return preservingGitHosted({
integrity: resolution['integrity'],
tarball: resolution['tarball'],
}
tarball,
}, gitHosted)
}
// Tarball URLs that cannot be reconstructed from the package name, version,
// and registry must always stay in the lockfile, otherwise the package can
// no longer be re-fetched. This covers tarballs served by git providers
// (GitHub, GitLab, Bitbucket).
if (tarball != null && gitHosted) {
return preservingGitHosted({
integrity: resolution['integrity'],
tarball,
}, gitHosted)
}
// Sometimes packages are hosted under non-standard tarball URLs.
// For instance, when they are hosted on npm Enterprise. See https://github.com/pnpm/pnpm/issues/867
@@ -211,6 +227,24 @@ function toLockfileResolution (
}
}
function preservingGitHosted<T extends { tarball?: string, integrity: string }> (
resolution: T,
gitHosted: boolean
): T & { gitHosted?: boolean } {
return gitHosted ? { ...resolution, gitHosted: true } : resolution
}
// Inlined to avoid pulling @pnpm/pick-fetcher into this dep graph.
// Used as a fallback when callers haven't pre-set the `gitHosted` field
// on TarballResolution.
function isGitHostedTarballUrl (url: string): boolean {
return (
url.startsWith('https://codeload.github.com/') ||
url.startsWith('https://bitbucket.org/') ||
url.startsWith('https://gitlab.com/')
) && url.includes('tar.gz')
}
function removeProtocol (url: string): string {
return url.split('://')[1]
}

3
pnpm-lock.yaml generated
View File

@@ -4053,9 +4053,6 @@ importers:
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../types
'@pnpm/pick-fetcher':
specifier: workspace:*
version: link:../../fetching/pick-fetcher
'@pnpm/resolver-base':
specifier: workspace:*
version: link:../../resolving/resolver-base

View File

@@ -42,7 +42,7 @@ export function createGitResolver (
const tarball = hosted.tarball?.()
if (tarball) {
resolution = { tarball }
resolution = { tarball, gitHosted: true }
}
}

View File

@@ -22,6 +22,7 @@ test('resolveFromGit() with commit', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#163360a8d3ae6bee9524541043197ff356f8ed99',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/163360a8d3ae6bee9524541043197ff356f8ed99',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -39,6 +40,7 @@ test('resolveFromGit() with no commit', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/1d7e288222b53a0cab90a331f1865220ec29560c',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -52,6 +54,7 @@ test('resolveFromGit() with no commit, when main branch is not master', async ()
normalizedBareSpecifier: 'github:zoli-forks/cmd-shim',
resolution: {
tarball: 'https://codeload.github.com/zoli-forks/cmd-shim/tar.gz/a00a83a1593edb6e395d3ce41f2ef70edf7e2cf5',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -64,6 +67,7 @@ test('resolveFromGit() with partial commit', async () => {
normalizedBareSpecifier: 'github:zoli-forks/cmd-shim#a00a83a',
resolution: {
tarball: 'https://codeload.github.com/zoli-forks/cmd-shim/tar.gz/a00a83a1593edb6e395d3ce41f2ef70edf7e2cf5',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -82,6 +86,7 @@ test('resolveFromGit() with branch', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#canary',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/4c39fbc124cd4944ee51cb082ad49320fab58121',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -94,6 +99,7 @@ test('resolveFromGit() with branch relative to refs', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#heads/canary',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/4c39fbc124cd4944ee51cb082ad49320fab58121',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -106,6 +112,7 @@ test('resolveFromGit() with tag', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -118,6 +125,7 @@ test.skip('resolveFromGit() with tag (v-prefixed tag)', async () => {
normalizedBareSpecifier: 'github:andreineculau/npm-publish-git#v0.0.7',
resolution: {
tarball: 'https://codeload.github.com/andreineculau/npm-publish-git/tar.gz/a2f8d94562884e9529cb12c0818312ac87ab7f0b',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -130,6 +138,7 @@ test('resolveFromGit() with strict semver', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#semver:1.0.0',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/163360a8d3ae6bee9524541043197ff356f8ed99',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -142,6 +151,7 @@ test.skip('resolveFromGit() with strict semver (v-prefixed tag)', async () => {
normalizedBareSpecifier: 'github:andreineculau/npm-publish-git#semver:v0.0.7',
resolution: {
tarball: 'https://codeload.github.com/andreineculau/npm-publish-git/tar.gz/a2f8d94562884e9529cb12c0818312ac87ab7f0b',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -154,6 +164,7 @@ test('resolveFromGit() with range semver', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#semver:^1.0.0',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/f7dec4d66a5a56719e49b9f94a24d73f924ddeb3',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -166,6 +177,7 @@ test.skip('resolveFromGit() with range semver (v-prefixed tag)', async () => {
normalizedBareSpecifier: 'github:andreineculau/npm-publish-git#semver:<=v0.0.7',
resolution: {
tarball: 'https://codeload.github.com/andreineculau/npm-publish-git/tar.gz/a2f8d94562884e9529cb12c0818312ac87ab7f0b',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -179,6 +191,7 @@ test('resolveFromGit() with sub folder', async () => {
resolution: {
tarball: 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22',
path: '/packages/simple-react-app',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -192,6 +205,7 @@ test('resolveFromGit() with both sub folder and branch', async () => {
resolution: {
tarball: 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/777e8a3e78cc89bbf41fb3fd9f6cf922d5463313',
path: '/packages/simple-react-app',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -258,6 +272,7 @@ test.skip('resolveFromGit() bitbucket with commit', async () => {
normalizedBareSpecifier: 'bitbucket:pnpmjs/git-resolver#988c61e11dc8d9ca0b5580cb15291951812549dc',
resolution: {
tarball: 'https://bitbucket.org/pnpmjs/git-resolver/get/988c61e11dc8d9ca0b5580cb15291951812549dc.tar.gz',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -301,6 +316,7 @@ test.skip('resolveFromGit() bitbucket with tag', async () => {
normalizedBareSpecifier: 'bitbucket:pnpmjs/git-resolver#0.3.4',
resolution: {
tarball: 'https://bitbucket.org/pnpmjs/git-resolver/get/87cf6a67064d2ce56e8cd20624769a5512b83ff9.tar.gz',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -328,6 +344,7 @@ test.skip('resolveFromGit() gitlab with commit', async () => {
normalizedBareSpecifier: 'gitlab:pnpm/git-resolver#988c61e11dc8d9ca0b5580cb15291951812549dc',
resolution: {
tarball: 'https://gitlab.com/api/v4/projects/pnpm%2Fgit-resolver/repository/archive.tar.gz?ref=988c61e11dc8d9ca0b5580cb15291951812549dc',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -371,6 +388,7 @@ test.skip('resolveFromGit() gitlab with tag', async () => {
normalizedBareSpecifier: 'gitlab:pnpm/git-resolver#0.3.4',
resolution: {
tarball: 'https://gitlab.com/api/v4/projects/pnpm%2Fgit-resolver/repository/archive.tar.gz?ref=87cf6a67064d2ce56e8cd20624769a5512b83ff9',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -383,6 +401,7 @@ test('resolveFromGit() normalizes full url', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -395,6 +414,7 @@ test('resolveFromGit() normalizes full url with port', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -407,6 +427,7 @@ test('resolveFromGit() normalizes full url (alternative form)', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
gitHosted: true,
},
resolvedVia: 'git-repository',
})
@@ -419,6 +440,7 @@ test('resolveFromGit() normalizes full url (alternative form 2)', async () => {
normalizedBareSpecifier: 'github:zkochan/is-negative#2.0.1',
resolution: {
tarball: 'https://codeload.github.com/zkochan/is-negative/tar.gz/6dcce91c268805d456b8a575b67d7febc7ae2933',
gitHosted: true,
},
resolvedVia: 'git-repository',
})

View File

@@ -17,6 +17,14 @@ export interface TarballResolution {
tarball: string
integrity?: string
path?: string
/**
* True for tarballs sourced from a git host (codeload.github.com /
* gitlab.com / bitbucket.org). Such tarballs need preparation
* (preparePackage / packlist) on extraction, and their cached content
* depends on whether build scripts ran, so they're addressed by
* gitHostedStoreIndexKey rather than the integrity-based key.
*/
gitHosted?: boolean
}
export interface BinaryResolution {

View File

@@ -15,7 +15,7 @@ import {
import loadJsonFile from 'load-json-file'
import { PnpmError } from '@pnpm/error'
import { type LicensePackage } from './licenses.js'
import { type DirectoryResolution, type PackageSnapshot, pkgSnapshotToResolution, type Resolution } from '@pnpm/lockfile.utils'
import { type DirectoryResolution, type PackageSnapshot, pkgSnapshotToResolution, type Resolution, type TarballResolution } from '@pnpm/lockfile.utils'
import { fetchFromDir } from '@pnpm/directory-fetcher'
const limitPkgReads = pLimit(4)
@@ -250,7 +250,11 @@ export async function readPackageIndexFile (
}
}
const isPackageWithIntegrity = 'integrity' in packageResolution
// Git-hosted resolutions are post-processed (preparePackage / packlist)
// on extraction, so their index file lives in the per-package store path
// even though integrity is now pinned in the lockfile.
const isGitHosted = !packageResolution.type && (packageResolution as TarballResolution).gitHosted === true
const isPackageWithIntegrity = !isGitHosted && 'integrity' in packageResolution
let pkgIndexFilePath
if (isPackageWithIntegrity) {

View File

@@ -39,17 +39,19 @@ export async function storeStatus (maybeOpts: StoreStatusOptions): Promise<strin
.filter(([depPath]) => !skipped.has(depPath))
.map(([depPath, pkgSnapshot]) => {
const id = packageIdFromSnapshot(depPath, pkgSnapshot)
const resolution = pkgSnapshot.resolution as TarballResolution
return {
depPath,
id,
integrity: (pkgSnapshot.resolution as TarballResolution).integrity,
integrity: resolution.integrity,
gitHosted: resolution.gitHosted === true,
pkgPath: depPath,
...nameVerFromPkgSnapshot(depPath, pkgSnapshot),
}
})
const modified = await pFilter(pkgs, async ({ id, integrity, depPath, name }) => {
const pkgIndexFilePath = integrity
const modified = await pFilter(pkgs, async ({ id, integrity, gitHosted, depPath, name }) => {
const pkgIndexFilePath = !gitHosted && integrity
? getIndexFilePathInCafs(storeDir, integrity, id)
: path.join(storeDir, dp.depPathToFilename(id, maybeOpts.virtualStoreDirMaxLength), 'integrity.json')
const { files } = await loadJsonFile<PackageFilesIndex>(pkgIndexFilePath)