@@ -2,11 +2,13 @@ import { HttpClient } from '@actions/http-client'
22import { spawn } from 'child_process'
33import { createHash } from 'crypto'
44import { createReadStream , createWriteStream , existsSync } from 'fs'
5- import { chmod , copyFile , link , mkdir , rm } from 'fs/promises'
5+ import { chmod , copyFile , link , mkdir , rename , rm } from 'fs/promises'
66import path from 'path'
77import { pipeline } from 'stream/promises'
88import semver from 'semver'
99
10+ import { type PackageSignature , verifyRegistrySignature } from './verify-signature'
11+
1012// The action downloads pnpm's self-contained release archive and uses
1113// `pnpm runtime` to install a JavaScript runtime. Both are available from v11
1214// onward, so that is the oldest major this action can install.
@@ -19,12 +21,46 @@ const ABBREVIATED_PACKUMENT = 'application/vnd.npm.install-v1+json'
1921
2022const GITHUB_API = 'https://api.github.com'
2123
22- export interface ResolvedPnpm {
23- readonly version : string
24- readonly downloadUrl : string
25- // Hex-encoded SHA-256 of the release archive, from the GitHub asset `digest`.
26- readonly sha256 : string
27- readonly archive : 'tar.gz' | 'zip'
24+ /**
25+ * Where the executable is fetched from, and what proves it is the right one.
26+ *
27+ * From v12 the packages on the npm registry hold the same executable as the
28+ * release assets, byte for byte, and npm signs a checksum for them with a key
29+ * this action pins — so a tampered download cannot pass. GitHub publishes a
30+ * digest but serves it from the same place as the asset, which catches
31+ * corruption rather than tampering, so it is used only where there is nothing
32+ * better: v11, whose `dist/` bundles dependencies the registry copy declares
33+ * instead.
34+ */
35+ export type ResolvedPnpm =
36+ | {
37+ readonly source : 'github'
38+ readonly version : string
39+ readonly downloadUrl : string
40+ // Hex-encoded SHA-256 of the release archive, from the GitHub asset `digest`.
41+ readonly sha256 : string
42+ readonly archive : 'tar.gz' | 'zip'
43+ }
44+ | {
45+ readonly source : 'registry'
46+ readonly version : string
47+ readonly packages : readonly RegistryPackage [ ]
48+ }
49+
50+ interface RegistryPackage {
51+ readonly name : string
52+ readonly tarball : string
53+ readonly integrity : string
54+ /** Entry to lift out of the tarball's `package/` root into the destination. */
55+ readonly keep : string
56+ }
57+
58+ interface VersionMetadata {
59+ readonly dist : {
60+ readonly tarball : string
61+ readonly integrity ?: string
62+ readonly signatures ?: readonly PackageSignature [ ]
63+ }
2864}
2965
3066interface AbbreviatedPackument {
@@ -53,6 +89,10 @@ To install older pnpm, use the pnpm/action-setup action instead.`)
5389 }
5490
5591 const platform = getPlatform ( )
92+ if ( semver . major ( version ) >= 12 ) {
93+ return resolveFromRegistry ( version , platform )
94+ }
95+
5696 const asset = assetName ( platform )
5797 const release = await fetchRelease ( version , token )
5898 const found = release . assets . find ( ( a ) => a . name === asset )
@@ -67,13 +107,42 @@ To install older pnpm, use the pnpm/action-setup action instead.`)
67107 throw new Error ( `Release asset ${ asset } for pnpm ${ version } has no sha256 digest (got ${ found . digest ?? '<missing>' } ).` )
68108 }
69109 return {
110+ source : 'github' ,
70111 version,
71112 downloadUrl : found . browser_download_url ,
72113 sha256 : found . digest . slice ( 'sha256:' . length ) ,
73114 archive : platform . os === 'win32' ? 'zip' : 'tar.gz' ,
74115 }
75116}
76117
118+ /**
119+ * The executable and the `dist/` tree it loads are published as two packages:
120+ * the platform package holds the binary, `pnpm` holds `dist/`. Both are
121+ * verified the same way.
122+ */
123+ async function resolveFromRegistry ( version : string , platform : Platform ) : Promise < ResolvedPnpm > {
124+ const exe = platform . os === 'win32' ? 'pnpm.exe' : 'pnpm'
125+ const wanted = [
126+ { name : platformPackageName ( platform ) , keep : exe } ,
127+ { name : 'pnpm' , keep : 'dist' } ,
128+ ]
129+ const packages = await Promise . all ( wanted . map ( async ( { name, keep } ) => {
130+ const meta = await fetchJson < VersionMetadata > ( `${ REGISTRY } /${ name } /${ version } ` )
131+ const integrity = meta . dist . integrity
132+ if ( ! integrity ) {
133+ throw new Error ( `The npm registry published no checksum for ${ name } @${ version } .` )
134+ }
135+ verifyRegistrySignature ( { name, version, integrity, signatures : meta . dist . signatures } )
136+ return { name, tarball : meta . dist . tarball , integrity, keep }
137+ } ) )
138+ return { source : 'registry' , version, packages }
139+ }
140+
141+ // Platform packages are named `@pnpm/exe.<os>-<arch>[-musl]`.
142+ function platformPackageName ( { os, arch, musl } : Platform ) : string {
143+ return `@pnpm/exe.${ os } -${ arch } ${ musl ? '-musl' : '' } `
144+ }
145+
77146/**
78147 * Downloads and extracts the pnpm release archive into `destDir`, returning the
79148 * path to the `pnpm` executable. The archive holds the executable at its root
@@ -86,16 +155,19 @@ export async function downloadPnpm(resolved: ResolvedPnpm, destDir: string): Pro
86155 const tmpDir = path . join ( destDir , '.download' )
87156 await mkdir ( tmpDir , { recursive : true } )
88157
89- const archivePath = path . join ( tmpDir , resolved . archive === 'zip' ? 'pnpm.zip' : 'pnpm.tgz' )
90- const response = await http . get ( resolved . downloadUrl )
91- if ( response . message . statusCode !== 200 ) {
92- response . message . resume ( )
93- throw new Error ( `Failed to download ${ resolved . downloadUrl } : HTTP ${ response . message . statusCode } ` )
158+ if ( resolved . source === 'registry' ) {
159+ await downloadFromRegistry ( resolved . packages , destDir , tmpDir )
160+ } else {
161+ const archivePath = path . join ( tmpDir , resolved . archive === 'zip' ? 'pnpm.zip' : 'pnpm.tgz' )
162+ const response = await http . get ( resolved . downloadUrl )
163+ if ( response . message . statusCode !== 200 ) {
164+ response . message . resume ( )
165+ throw new Error ( `Failed to download ${ resolved . downloadUrl } : HTTP ${ response . message . statusCode } ` )
166+ }
167+ await pipeline ( response . message , createWriteStream ( archivePath ) )
168+ await verifySha256 ( archivePath , resolved . sha256 , resolved . downloadUrl )
169+ await extractArchive ( archivePath , destDir , resolved . archive )
94170 }
95- await pipeline ( response . message , createWriteStream ( archivePath ) )
96- await verifySha256 ( archivePath , resolved . sha256 , resolved . downloadUrl )
97-
98- await extractArchive ( archivePath , destDir , resolved . archive )
99171 await rm ( tmpDir , { recursive : true , force : true } )
100172
101173 const exe = process . platform === 'win32' ? 'pnpm.exe' : 'pnpm'
@@ -216,6 +288,47 @@ async function fetchJson<T>(url: string, headers?: Record<string, string>): Prom
216288 return response . result
217289}
218290
291+ /**
292+ * Fetches each package, checks it against the checksum npm signed for it, and
293+ * lifts the wanted entry out of the tarball's `package/` root. Unpacking
294+ * happens away from `destDir`, whose layout the executable depends on.
295+ */
296+ async function downloadFromRegistry (
297+ packages : readonly RegistryPackage [ ] ,
298+ destDir : string ,
299+ tmpDir : string ,
300+ ) : Promise < void > {
301+ for ( const pkg of packages ) {
302+ const safeName = pkg . name . replace ( / [ @ / ] / g, '_' )
303+ const archivePath = path . join ( tmpDir , `${ safeName } .tgz` )
304+ const response = await http . get ( pkg . tarball )
305+ if ( response . message . statusCode !== 200 ) {
306+ response . message . resume ( )
307+ throw new Error ( `Failed to download ${ pkg . tarball } : HTTP ${ response . message . statusCode } ` )
308+ }
309+ await pipeline ( response . message , createWriteStream ( archivePath ) )
310+ await verifyIntegrity ( archivePath , pkg )
311+
312+ const unpackDir = path . join ( tmpDir , safeName )
313+ await mkdir ( unpackDir , { recursive : true } )
314+ await extractArchive ( archivePath , unpackDir , 'tar.gz' )
315+ await rm ( path . join ( destDir , pkg . keep ) , { recursive : true , force : true } )
316+ await rename ( path . join ( unpackDir , 'package' , pkg . keep ) , path . join ( destDir , pkg . keep ) )
317+ }
318+ }
319+
320+ async function verifyIntegrity ( file : string , pkg : RegistryPackage ) : Promise < void > {
321+ const [ algorithm , expected ] = pkg . integrity . split ( '-' )
322+ const hash = createHash ( algorithm )
323+ await pipeline ( createReadStream ( file ) , hash )
324+ const actual = hash . digest ( 'base64' )
325+ if ( actual !== expected ) {
326+ throw new Error ( `${ pkg . name } @${ pkg . integrity } does not match the checksum the npm registry published for it. Refusing to install.
327+ Expected ${ algorithm } : ${ expected }
328+ Actual ${ algorithm } : ${ actual } ` )
329+ }
330+ }
331+
219332async function verifySha256 ( file : string , expectedHex : string , url : string ) : Promise < void > {
220333 const hash = createHash ( 'sha256' )
221334 await pipeline ( createReadStream ( file ) , hash )
0 commit comments