mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-18 11:42:19 -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.
192 lines
6.5 KiB
TypeScript
192 lines
6.5 KiB
TypeScript
// cspell:ignore ents
|
|
import fs from 'node:fs'
|
|
|
|
import { type LockfileObject, type PackageSnapshot, readWantedLockfile, type TarballResolution } from '@pnpm/lockfile.fs'
|
|
import {
|
|
nameVerFromPkgSnapshot,
|
|
} from '@pnpm/lockfile.utils'
|
|
import { getFilePathByModeInCafs, type PackageFilesIndex } from '@pnpm/store.cafs'
|
|
import { pickStoreIndexKey, StoreIndex } from '@pnpm/store.index'
|
|
import type { DepPath } from '@pnpm/types'
|
|
import Fuse from 'fuse-native'
|
|
import schemas from 'hyperdrive-schemas'
|
|
|
|
import * as cafsExplorer from './cafsExplorer.js'
|
|
import { makeVirtualNodeModules } from './makeVirtualNodeModules.js'
|
|
|
|
const TIME = new Date()
|
|
const STAT_DEFAULT = {
|
|
mtime: TIME,
|
|
atime: TIME,
|
|
ctime: TIME,
|
|
nlink: 1,
|
|
uid: process.getuid ? process.getuid() : 0,
|
|
gid: process.getgid ? process.getgid() : 0,
|
|
}
|
|
|
|
export interface FuseHandlers {
|
|
open: (p: string, flags: string | number, cb: (exitCode: number, fd?: number) => void) => void
|
|
release: (p: string, fd: number, cb: (exitCode: number) => void) => void
|
|
read: (p: string, fd: number, buffer: Buffer, length: number, position: number, cb: (readBytes: number) => void) => void
|
|
readlink: (p: string, cb: (returnCode: number, target?: string) => void) => void
|
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
getattr: (p: string, cb: (returnCode: number, files?: any) => void) => void
|
|
readdir: (p: string, cb: (returnCode: number, files?: string[]) => void) => void
|
|
}
|
|
|
|
export async function createFuseHandlers (lockfileDir: string, storeDir: string): Promise<FuseHandlers> {
|
|
const lockfile = await readWantedLockfile(lockfileDir, { ignoreIncompatible: true })
|
|
if (lockfile == null) throw new Error('Cannot generate a .pnp.cjs without a lockfile')
|
|
return createFuseHandlersFromLockfile(lockfile, storeDir)
|
|
}
|
|
|
|
export function createFuseHandlersFromLockfile (lockfile: LockfileObject, storeDir: string): FuseHandlers {
|
|
const storeIndex = new StoreIndex(storeDir)
|
|
const pkgSnapshotCache = new Map<string, { name: string, version: string, pkgSnapshot: PackageSnapshot, index: PackageFilesIndex }>()
|
|
const virtualNodeModules = makeVirtualNodeModules(lockfile)
|
|
return {
|
|
open (p: string, flags: string | number, cb: (exitCode: number, fd?: number) => void) {
|
|
const dirEnt = getDirEnt(p)
|
|
if (dirEnt?.entryType !== 'index') {
|
|
cb(-1)
|
|
return
|
|
}
|
|
const fileInfo = dirEnt.index.files.get(dirEnt.subPath)
|
|
if (!fileInfo) {
|
|
cb(-1)
|
|
return
|
|
}
|
|
const filePathInStore = getFilePathByModeInCafs(storeDir, fileInfo.digest, fileInfo.mode)
|
|
fs.open(filePathInStore, flags, (err, fd) => {
|
|
if (err != null) {
|
|
cb(-1)
|
|
return
|
|
}
|
|
cb(0, fd)
|
|
})
|
|
},
|
|
release (p: string, fd: number, cb: (exitCode: number) => void) {
|
|
fs.close(fd, (err) => {
|
|
cb((err != null) ? -1 : 0)
|
|
})
|
|
},
|
|
read (p: string, fd: number, buffer: Buffer, length: number, position: number, cb: (readBytes: number) => void) {
|
|
fs.read(fd, buffer, 0, length, position, (err, bytesRead) => {
|
|
if (err != null) {
|
|
cb(-1)
|
|
return
|
|
}
|
|
cb(bytesRead)
|
|
})
|
|
},
|
|
readlink (p: string, cb: (returnCode: number, target?: string) => void) {
|
|
const dirEnt = getDirEnt(p)
|
|
if (dirEnt?.entryType !== 'symlink') {
|
|
cb(Fuse.ENOENT)
|
|
return
|
|
}
|
|
cb(0, dirEnt.target)
|
|
},
|
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
getattr (p: string, cb: (returnCode: number, files?: any) => void) {
|
|
const dirEnt = getDirEnt(p)
|
|
if (dirEnt == null) {
|
|
cb(Fuse.ENOENT)
|
|
return
|
|
}
|
|
if (dirEnt.entryType === 'directory' || dirEnt.entryType === 'index' && !dirEnt.subPath) {
|
|
cb(0, schemas.Stat.directory({
|
|
...STAT_DEFAULT,
|
|
size: 1,
|
|
}))
|
|
return
|
|
}
|
|
if (dirEnt.entryType === 'symlink') {
|
|
cb(0, schemas.Stat.symlink({
|
|
...STAT_DEFAULT,
|
|
size: 1,
|
|
}))
|
|
return
|
|
}
|
|
if (dirEnt.entryType === 'index') {
|
|
switch (cafsExplorer.dirEntityType(dirEnt.index, dirEnt.subPath)) {
|
|
case 'file': {
|
|
const fileInfo = dirEnt.index.files.get(dirEnt.subPath)!
|
|
cb(0, schemas.Stat.file({
|
|
...STAT_DEFAULT,
|
|
mode: fileInfo.mode,
|
|
size: fileInfo.size,
|
|
}))
|
|
return
|
|
}
|
|
case 'directory':
|
|
cb(0, schemas.Stat.directory({
|
|
...STAT_DEFAULT,
|
|
size: 1,
|
|
}))
|
|
return
|
|
default:
|
|
cb(Fuse.ENOENT)
|
|
return
|
|
}
|
|
}
|
|
cb(Fuse.ENOENT)
|
|
},
|
|
readdir,
|
|
}
|
|
function readdir (p: string, cb: (returnCode: number, files?: string[]) => void) {
|
|
const dirEnt = getDirEnt(p)
|
|
if (dirEnt?.entryType === 'index') {
|
|
const dirEnts = cafsExplorer.readdir(dirEnt.index, dirEnt.subPath)
|
|
if (dirEnts.length === 0) {
|
|
cb(Fuse.ENOENT)
|
|
return
|
|
}
|
|
cb(0, dirEnts)
|
|
return
|
|
}
|
|
if ((dirEnt == null) || dirEnt.entryType !== 'directory') {
|
|
cb(Fuse.ENOENT)
|
|
return
|
|
}
|
|
cb(0, Object.keys(dirEnt.entries))
|
|
}
|
|
function getDirEnt (p: string) {
|
|
let currentDirEntry = virtualNodeModules
|
|
const parts = p === '/' ? [] : p.split('/')
|
|
parts.shift()
|
|
while ((parts.length > 0) && currentDirEntry && currentDirEntry.entryType === 'directory') {
|
|
currentDirEntry = currentDirEntry.entries[parts.shift()!]
|
|
}
|
|
if (currentDirEntry?.entryType === 'index') {
|
|
const pkg = getPkgInfo(currentDirEntry.depPath)
|
|
if (pkg == null) {
|
|
return null
|
|
}
|
|
return {
|
|
...currentDirEntry,
|
|
index: pkg.index,
|
|
subPath: parts.join('/'),
|
|
}
|
|
}
|
|
return currentDirEntry
|
|
}
|
|
function getPkgInfo (depPath: string) {
|
|
if (!pkgSnapshotCache.has(depPath)) {
|
|
const pkgSnapshot = lockfile.packages?.[depPath as DepPath]
|
|
if (pkgSnapshot == null) return undefined
|
|
const nameVer = nameVerFromPkgSnapshot(depPath, pkgSnapshot)
|
|
const resolution = pkgSnapshot.resolution as TarballResolution
|
|
const pkgId = nameVer.nonSemverVersion ?? `${nameVer.name}@${nameVer.version}`
|
|
const pkgIndexFilePath = pickStoreIndexKey(resolution, pkgId, { built: true })
|
|
const pkgIndex = storeIndex.get(pkgIndexFilePath) as PackageFilesIndex
|
|
pkgSnapshotCache.set(depPath, {
|
|
...nameVer,
|
|
pkgSnapshot,
|
|
index: pkgIndex,
|
|
})
|
|
}
|
|
return pkgSnapshotCache.get(depPath)
|
|
}
|
|
}
|