66 * Nothing reaches npmjs for the package under test: the registry has no uplink for the scope, so a
77 * package that failed to publish cannot be quietly satisfied by the real one. Everything else is
88 * proxied to npmjs.
9+ *
10+ * The registry listens on 127.0.0.1, and another local account could hold that port first. So the
11+ * port has to be free before Verdaccio starts, Verdaccio has to stay up, and it has to answer with a
12+ * per-run secret seeded into its storage (the work folder is private to this account) before any
13+ * package command trusts it. The package under test is then checked byte for byte against the
14+ * tarball, both as the registry serves it and as each package manager installs it.
915 */
1016import { execFileSync , spawn } from 'node:child_process' ;
17+ import { createHash , randomBytes } from 'node:crypto' ;
1118import {
1219 appendFileSync ,
1320 existsSync ,
1421 mkdirSync ,
1522 readFileSync ,
1623 readdirSync ,
24+ realpathSync ,
1725 writeFileSync ,
1826} from 'node:fs' ;
27+ import { createServer } from 'node:net' ;
1928import path from 'node:path' ;
2029import { fileURLToPath } from 'node:url' ;
2130
@@ -90,8 +99,22 @@ export function wireOverlayInMain(file) {
9099 * Every long-running process it starts is stopped by `stopAll()` and when the process exits.
91100 */
92101export function createRegistry ( { work, registryPort } ) {
93- const REGISTRY = `http://localhost:${ registryPort } ` ;
102+ // 127.0.0.1 rather than localhost, so every client reaches the one socket Verdaccio holds and not
103+ // a listener on ::1.
104+ const REGISTRY = `http://127.0.0.1:${ registryPort } ` ;
105+ const IDENTITY_PACKAGE = '@pangular-inspector/registry-identity' ;
94106 const children = new Set ( ) ;
107+ let registryChild ;
108+ let publishedTarball ;
109+
110+ /** Fails when the registry was started and has stopped, as nothing else may answer its port. */
111+ function assertRegistryRunning ( ) {
112+ if ( registryChild && ( registryChild . exitCode !== null || registryChild . signalCode !== null ) ) {
113+ throw new Error ( `the local registry exited:\n${ tail ( registryChild . logFile ) } ` ) ;
114+ }
115+ }
116+
117+ const usesRegistry = ( env ) => env ?. npm_config_registry === REGISTRY ;
95118
96119 function stopAll ( ) {
97120 for ( const child of children ) {
@@ -142,6 +165,7 @@ export function createRegistry({ work, registryPort }) {
142165 } ) ;
143166
144167 function run ( cmd , args , cwd , env = registryEnv ( ) , timeout = 15 * 60_000 ) {
168+ if ( usesRegistry ( env ) ) assertRegistryRunning ( ) ;
145169 try {
146170 return execFileSync ( cmd , args , {
147171 cwd,
@@ -161,6 +185,7 @@ export function createRegistry({ work, registryPort }) {
161185
162186 /** A long-running process in its own group, so stopping it also stops what it started. */
163187 function start ( cmd , args , cwd , env , logFile ) {
188+ if ( usesRegistry ( env ) ) assertRegistryRunning ( ) ;
164189 const child = spawn ( cmd , args , { cwd, env, detached : true , stdio : [ 'ignore' , 'pipe' , 'pipe' ] } ) ;
165190 children . add ( child ) ;
166191 const log = ( chunk ) => appendFileSync ( logFile , chunk ) ;
@@ -170,19 +195,74 @@ export function createRegistry({ work, registryPort }) {
170195 return child ;
171196 }
172197
198+ /**
199+ * Waits for `url` to answer while `child` is running. An answer after `child` exited comes from
200+ * something else on the port, so it fails rather than counting as ready.
201+ */
173202 async function waitFor ( url , child , logFile , seconds ) {
174- for ( let tries = 0 ; tries < seconds ; tries ++ ) {
203+ const exited = ( ) => child . exitCode !== null || child . signalCode !== null ;
204+ for ( let tries = 0 ; tries < seconds && ! exited ( ) ; tries ++ ) {
175205 const response = await fetch ( url ) . catch ( ( ) => undefined ) ;
176- if ( response ?. ok ) return response ;
177- if ( child . exitCode !== null ) break ;
206+ if ( response ?. ok && ! exited ( ) ) return response ;
178207 await new Promise ( ( resolve ) => setTimeout ( resolve , 1000 ) ) ;
179208 }
180- throw new Error ( `${ url } did not answer:\n${ tail ( logFile ) } ` ) ;
209+ const why = exited ( ) ? 'its process exited' : `it did not answer in ${ seconds } s` ;
210+ throw new Error ( `${ url } is not ready, ${ why } :\n${ tail ( logFile ) } ` ) ;
211+ }
212+
213+ /** Fails when something already listens on 127.0.0.1:`port`. */
214+ function assertPortFree ( port ) {
215+ return new Promise ( ( resolve , reject ) => {
216+ const probe = createServer ( ) ;
217+ probe . once ( 'error' , ( error ) =>
218+ reject (
219+ new Error ( `127.0.0.1:${ port } is taken (${ error . code } ); set VERIFY_PORT to a free port` ) ,
220+ ) ,
221+ ) ;
222+ probe . listen ( { port, host : '127.0.0.1' , exclusive : true } , ( ) => probe . close ( resolve ) ) ;
223+ } ) ;
224+ }
225+
226+ /**
227+ * Seeds Verdaccio's storage with a package whose readme is a random secret. Only the Verdaccio
228+ * this run started reads that storage, so an answer with the secret proves who holds the port.
229+ */
230+ function seedIdentity ( storageDir ) {
231+ const secret = randomBytes ( 32 ) . toString ( 'hex' ) ;
232+ const dir = path . join ( storageDir , IDENTITY_PACKAGE ) ;
233+ mkdirSync ( dir , { recursive : true } ) ;
234+ writeFileSync (
235+ path . join ( dir , 'package.json' ) ,
236+ JSON . stringify ( {
237+ name : IDENTITY_PACKAGE ,
238+ versions : { } ,
239+ 'dist-tags' : { } ,
240+ time : { } ,
241+ _rev : '0-0' ,
242+ _uplinks : { } ,
243+ _distfiles : { } ,
244+ _attachments : { } ,
245+ readme : secret ,
246+ } ) ,
247+ ) ;
248+ return secret ;
249+ }
250+
251+ async function checkIdentity ( secret ) {
252+ const url = `${ REGISTRY } /${ IDENTITY_PACKAGE . replace ( '/' , '%2f' ) } ` ;
253+ const response = await fetch ( url ) . catch ( ( ) => undefined ) ;
254+ const body = await response ?. json ( ) . catch ( ( ) => undefined ) ;
255+ if ( body ?. readme !== secret ) {
256+ throw new Error (
257+ `${ REGISTRY } answered without this run's secret (${ response ?. status ?? 'no answer' } ): another process may hold the port` ,
258+ ) ;
259+ }
181260 }
182261
183262 async function startRegistry ( ) {
184263 const storage = path . join ( work , 'registry' ) ;
185264 mkdirSync ( storage ) ;
265+ const secret = seedIdentity ( path . join ( storage , 'storage' ) ) ;
186266 const config = path . join ( storage , 'config.yaml' ) ;
187267 writeFileSync (
188268 config ,
@@ -208,15 +288,19 @@ log: { type: stdout, format: pretty, level: warn }
208288` ,
209289 ) ;
210290 const logFile = path . join ( work , 'registry.log' ) ;
291+ await assertPortFree ( registryPort ) ;
211292 console . log ( `starting Verdaccio on ${ REGISTRY } ` ) ;
212293 const child = start (
213294 'pnpm' ,
214- [ 'dlx' , 'verdaccio@6' , '--config' , config , '--listen' , `localhost :${ registryPort } ` ] ,
295+ [ 'dlx' , 'verdaccio@6' , '--config' , config , '--listen' , `127.0.0.1 :${ registryPort } ` ] ,
215296 storage ,
216297 process . env ,
217298 logFile ,
218299 ) ;
300+ registryChild = child ;
219301 await waitFor ( `${ REGISTRY } /-/ping` , child , logFile , 180 ) ;
302+ await checkIdentity ( secret ) ;
303+ assertRegistryRunning ( ) ;
220304
221305 // Publishing needs a user. Verdaccio creates one on this request and returns its token.
222306 const response = await fetch ( `${ REGISTRY } /-/user/org.couchdb.user:verify` , {
@@ -228,7 +312,7 @@ log: { type: stdout, format: pretty, level: warn }
228312 if ( ! token ) throw new Error ( `Verdaccio returned no token (${ response . status } )` ) ;
229313 writeFileSync (
230314 path . join ( work , '.npmrc' ) ,
231- `registry=${ REGISTRY } \n//localhost :${ registryPort } /:_authToken=${ token } \n` ,
315+ `registry=${ REGISTRY } \n//127.0.0.1 :${ registryPort } /:_authToken=${ token } \n` ,
232316 ) ;
233317 return child ;
234318 }
@@ -265,13 +349,69 @@ log: { type: stdout, format: pretty, level: warn }
265349 return published . version ;
266350 }
267351
268- function publish ( tarball ) {
352+ /** Publishes `tarball`, then checks the registry serves exactly that file for the version. */
353+ async function publish ( tarball ) {
269354 const version = checkTarball ( tarball ) ;
270355 console . log ( `publishing ${ manifest . name } @${ version } to ${ REGISTRY } ` ) ;
271356 run ( 'npm' , [ 'publish' , tarball , '--registry' , REGISTRY , '--access' , 'public' ] , work ) ;
357+ const bytes = readFileSync ( tarball ) ;
358+ const integrity = `sha512-${ createHash ( 'sha512' ) . update ( bytes ) . digest ( 'base64' ) } ` ;
359+ const packument = await (
360+ await fetch ( `${ REGISTRY } /${ manifest . name . replace ( '/' , '%2f' ) } ` )
361+ ) . json ( ) ;
362+ const dist = packument . versions ?. [ version ] ?. dist ;
363+ if ( dist ?. integrity !== integrity ) {
364+ throw new Error (
365+ `${ REGISTRY } lists ${ manifest . name } @${ version } as ${ dist ?. integrity } , not the tarball's ${ integrity } ` ,
366+ ) ;
367+ }
368+ const served = Buffer . from ( await ( await fetch ( dist . tarball ) ) . arrayBuffer ( ) ) ;
369+ if ( ! served . equals ( bytes ) ) {
370+ throw new Error ( `${ dist . tarball } is not the tarball that was published` ) ;
371+ }
372+ assertRegistryRunning ( ) ;
373+ publishedTarball = tarball ;
272374 return version ;
273375 }
274376
377+ const sha256 = ( file ) => createHash ( 'sha256' ) . update ( readFileSync ( file ) ) . digest ( 'hex' ) ;
378+
379+ /**
380+ * Checks every file of the published tarball is in `dir/node_modules/<package>` with the same
381+ * content, so a package manager installed the archive this run selected and nothing else.
382+ */
383+ function verifyInstalled ( dir ) {
384+ if ( ! publishedTarball ) throw new Error ( 'verifyInstalled runs after publish' ) ;
385+ const expected = path . join ( work , 'published' ) ;
386+ if ( ! existsSync ( expected ) ) {
387+ mkdirSync ( expected ) ;
388+ run ( 'tar' , [ '-xzf' , publishedTarball , '-C' , expected ] , work , process . env ) ;
389+ }
390+ const installed = path . join ( dir , 'node_modules' , manifest . name ) ;
391+ if ( ! existsSync ( installed ) ) throw new Error ( `${ manifest . name } is not in ${ dir } /node_modules` ) ;
392+ const installedRoot = realpathSync ( installed ) ;
393+ const packageRoot = path . join ( expected , 'package' ) ;
394+ const differ = [ ] ;
395+ const walk = ( from ) => {
396+ for ( const entry of readdirSync ( from , { withFileTypes : true } ) ) {
397+ const file = path . join ( from , entry . name ) ;
398+ if ( entry . isDirectory ( ) ) {
399+ walk ( file ) ;
400+ continue ;
401+ }
402+ const relative = path . relative ( packageRoot , file ) ;
403+ const target = path . join ( installedRoot , relative ) ;
404+ if ( ! existsSync ( target ) || sha256 ( target ) !== sha256 ( file ) ) differ . push ( relative ) ;
405+ }
406+ } ;
407+ walk ( packageRoot ) ;
408+ if ( differ . length ) {
409+ throw new Error (
410+ `${ installedRoot } does not match the published tarball:\n ${ differ . slice ( 0 , 20 ) . join ( '\n ' ) } ` ,
411+ ) ;
412+ }
413+ }
414+
275415 /** The newest version of `name` inside `range`, from npmjs through the registry's proxy. */
276416 function newest ( name , range ) {
277417 const found = JSON . parse ( run ( 'npm' , [ 'view' , `${ name } @${ range } ` , 'version' , '--json' ] , work ) ) ;
@@ -293,6 +433,7 @@ log: { type: stdout, format: pretty, level: warn }
293433 readTarball,
294434 checkTarball,
295435 publish,
436+ verifyInstalled,
296437 newest,
297438 } ;
298439}
0 commit comments