mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-10 17:34:53 -04:00
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.
231 lines
6.6 KiB
TypeScript
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>
|