Files
pnpm/resolving/resolver-base/src/index.ts
Zoltan Kochan 27425d7bfc fix: pin integrity of git-hosted tarballs in lockfile (#11481)
For git-hosted tarballs (`codeload.github.com` / `gitlab.com` / `bitbucket.org`) the fetcher dropped the integrity it computed while downloading, so the lockfile only ever 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 — the lockfile had no hash to compare against.

This pins the SHA-512 SRI of the raw tarball in the lockfile, in the same `sha512-<base64>` form npm-registry tarballs use. The only difference is the source: for npm we pass through `dist.integrity`, for git we compute it locally from the downloaded buffer. Subsequent installs validate the download against that integrity in the worker (`addTarballToStore` → `parseIntegrity` → hash compare), so a tampered tarball fails with `TarballIntegrityError`.

## Why git-hosted stays on `gitHostedStoreIndexKey`

The lockfile pins integrity for security, but the *store key* for git-hosted resolutions stays on `gitHostedStoreIndexKey(pkgId, { built })` rather than collapsing under the integrity-based key. Reason: git-hosted tarballs are post-processed (`preparePackage` / `packlist`), so the cached file set depends on whether build scripts ran during fetch. The integrity-only key would fold the built and not-built variants into a single slot, letting one overwrite the other and serving the wrong content if `ignoreScripts` was toggled between runs. Keeping git-hosted on the existing key shape preserves that dimension; the integrity is still validated on every fresh download.

## How the routing stays clean

The naive way to express "use gitHostedStoreIndexKey for git-hosted, integrity key for npm" is to call `isGitHostedPkgUrl(resolution.tarball)` everywhere a store key is computed — fragile, scattered, and easy to forget when adding new readers (Copilot caught two of those during review). Instead, a typed annotation: `TarballResolution` gets an optional `gitHosted: boolean` field. The git resolver sets it; the lockfile loader (`convertToLockfileObject`) backfills it for entries written by older pnpm versions; `toLockfileResolution` carries it through on serialize. Every consumer reads `resolution.gitHosted` directly. URL detection lives in exactly two places — the resolver and the loader — instead of seven.

## Changes

### Security fix
- `fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts` — return the `integrity` that the inner remote-tarball fetch already computed (was being silently dropped by the destructure).

### Lockfile schema (additive)
- `@pnpm/lockfile.types` and `@pnpm/resolving.resolver-base` — `TarballResolution` gains optional `gitHosted: boolean`.
- `@pnpm/resolving.git-resolver` — sets `gitHosted: true` on every git-hosted tarball it produces.
- `@pnpm/lockfile.fs` (`convertToLockfileObject`) — backfills the field on load for older lockfiles via inlined URL detection.
- `@pnpm/lockfile.utils` (`toLockfileResolution`, `pkgSnapshotToResolution`) — preserve / read the field.

### Store-key consumers (now one-line typed reads, dropped the URL-sniffing dep)
- `installing/package-requester` (`getFilesIndexFilePath`)
- `store/pkg-finder` (`readPackageFileMap`)
- `modules-mounter/daemon` (`createFuseHandlers`)
- `building/after-install` (side-effects-cache lookup + write)
- `store/commands/storeStatus`
- `installing/deps-installer` (agent-mode store-controller wrapper)

### Fetcher routing
- `fetching/pick-fetcher` — `pickFetcher` prefers `resolution.gitHosted`; URL fallback retained for ad-hoc resolutions.

### Tests
- New integrity-validation test in `tarball-fetcher` (mismatched `integrity` on the resolution must throw `TarballIntegrityError`).
- New git-hosted lookup test in `pkg-finder` asserting routing through `gitHostedStoreIndexKey` even when integrity is present.
- New `toLockfileResolution` test asserting `gitHosted: true` flows through serialization.
- `fromRepo.ts` lockfile snapshot updated for the now-pinned integrity + `gitHosted: true`.
- `git-resolver` tests updated to assert `gitHosted: true` in produced resolutions.
2026-05-06 17:41:30 +02:00

231 lines
6.6 KiB
TypeScript

import type {
DependencyManifest,
PackageVersionPolicy,
PinnedVersion,
PkgResolutionId,
ProjectRootDir,
SupportedArchitectures,
TrustPolicy,
} from '@pnpm/types'
export { type PkgResolutionId }
/**
* tarball hosted remotely
*/
export interface TarballResolution {
type?: undefined
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 {
type: 'binary'
archive: 'tarball' | 'zip'
url: string
integrity: string
bin: string | Record<string, string>
prefix?: string
}
/**
* directory on a file system
*/
export interface DirectoryResolution {
type: 'directory'
directory: string
}
export interface GitResolution {
commit: string
repo: string
path?: string
type: 'git'
}
export interface CustomResolution {
type: `custom:${string}` // e.g., 'custom:cdn', 'custom:artifactory'
[key: string]: unknown
}
export interface PlatformAssetTarget {
os: string
cpu: string
libc?: 'musl'
}
export interface PlatformAssetResolution {
resolution: AtomicResolution
targets: PlatformAssetTarget[]
}
export type AtomicResolution =
| TarballResolution
| DirectoryResolution
| GitResolution
| BinaryResolution
| CustomResolution
export interface VariationsResolution {
type: 'variations'
variants: PlatformAssetResolution[]
}
export type Resolution = AtomicResolution | VariationsResolution
/** Concrete platform selector used when picking a variant from a VariationsResolution. */
export interface PlatformSelector {
os: string
cpu: string
/** Name of the libc family requested. Omit (or leave `null`) for the default (glibc on Linux, n/a elsewhere). */
libc?: string | null
}
/**
* Resolve a {@link PlatformSelector} from the user's supportedArchitectures config
* and the host's own platform/arch/libc. When `supportedArchitectures.xxx` is set
* and its first entry is not `"current"`, that entry wins; otherwise the host's
* value is used. Additional entries beyond the first are ignored — variant
* selection picks exactly one (os, cpu, libc) triplet per install.
*/
export function resolvePlatformSelector (
supportedArchitectures: SupportedArchitectures | undefined,
host: { platform: string, arch: string, libc: string | null | undefined }
): PlatformSelector {
return {
os: pickFirstNonCurrent(supportedArchitectures?.os) ?? host.platform,
cpu: pickFirstNonCurrent(supportedArchitectures?.cpu) ?? host.arch,
libc: pickFirstNonCurrent(supportedArchitectures?.libc) ?? host.libc,
}
}
/**
* Pick the variant whose target matches the given selector, or `undefined` if
* none does. A variant with no `libc` represents the "default" build — glibc on
* Linux, irrelevant on macOS/Windows. A non-default libc (e.g. `musl`) is a
* separate, non-interchangeable artifact; an exact libc match is required in
* that case so the glibc/default variant doesn't silently win (its `target.libc`
* is nullish).
*/
export function selectPlatformVariant (
variants: PlatformAssetResolution[],
selector: PlatformSelector
): PlatformAssetResolution | undefined {
return variants.find((variant) => variant.targets.some((target) =>
target.os === selector.os &&
target.cpu === selector.cpu &&
libcMatches(target.libc, selector.libc)
))
}
function libcMatches (variantLibc: string | undefined, requestedLibc: string | null | undefined): boolean {
if (requestedLibc == null || requestedLibc === 'glibc') {
return variantLibc == null
}
return variantLibc === requestedLibc
}
function pickFirstNonCurrent (requirements: string[] | undefined): string | undefined {
if (requirements?.length && requirements[0] !== 'current') {
return requirements[0]
}
return undefined
}
export interface ResolveResult {
id: PkgResolutionId
latest?: string
publishedAt?: string
manifest?: DependencyManifest
resolution: Resolution
resolvedVia: string
normalizedBareSpecifier?: string
alias?: string
}
export interface WorkspacePackage {
rootDir: ProjectRootDir
manifest: DependencyManifest
}
export type WorkspacePackagesByVersion = Map<string, WorkspacePackage>
export type WorkspacePackages = Map<string, WorkspacePackagesByVersion>
// This weight is set for selectors that are used on direct dependencies.
// It is important to give a bigger weight to direct dependencies.
export const DIRECT_DEP_SELECTOR_WEIGHT = 1000
// This weight is set for concrete versions of dependencies preexisting in the
// wanted lockfile. When adding a dependency, prefer existing versions first.
//
// This needs to be a higher weight than DIRECT_DEP_SELECTOR_WEIGHT since direct
// dependency specifiers can match a range of versions. Versions on the registry
// not present in the lockfile should be considered at a lower weight than
// matching pre-existing versions. If this is not the case, pnpm could suddenly
// introduce a new version in the lockfile when an existing version works.
export const EXISTING_VERSION_SELECTOR_WEIGHT = 1_000_000
export type VersionSelectorType = 'version' | 'range' | 'tag'
export interface VersionSelectors {
[selector: string]: VersionSelectorWithWeight | VersionSelectorType
}
export interface VersionSelectorWithWeight {
selectorType: VersionSelectorType
weight: number
}
export interface PreferredVersions {
[packageName: string]: VersionSelectors
}
export interface ResolveOptions {
alwaysTryWorkspacePackages?: boolean
trustPolicy?: TrustPolicy
trustPolicyExclude?: PackageVersionPolicy
trustPolicyIgnoreAfter?: number
defaultTag?: string
pickLowestVersion?: boolean
publishedBy?: Date
publishedByExclude?: PackageVersionPolicy
projectDir: string
lockfileDir: string
preferredVersions: PreferredVersions
preferWorkspacePackages?: boolean
workspacePackages?: WorkspacePackages
update?: false | 'compatible' | 'latest'
injectWorkspacePackages?: boolean
calcSpecifier?: boolean
pinnedVersion?: PinnedVersion
currentPkg?: {
id: PkgResolutionId
name?: string
version?: string
resolution: Resolution
}
}
export type WantedDependency = {
injected?: boolean
prevSpecifier?: string
} & ({
alias?: string
bareSpecifier: string
} | {
alias: string
bareSpecifier?: string
})
export type ResolveFunction = (wantedDependency: WantedDependency & { optional?: boolean }, opts: ResolveOptions) => Promise<ResolveResult>