Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions scripts/__tests__/npm-publish-guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Buffer } from 'node:buffer'
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'

Expand All @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
evaluateNpmPublishMode,
executeFrozenPublish,
extractPnpmPackRecord,
freezeApprovedTarballs,
npmOidcAudience,
preparePublishWorkspaceDependencies,
Expand Down Expand Up @@ -268,25 +269,79 @@ describe('npm publish guard', () => {
).resolves.toMatchObject({ complete: false })
})

it('freezes local pnpm-pack bytes for a source package and its alias with absolute filenames', async () => {
it('extracts exactly one documented pnpm pack record from lifecycle output', () => {
const metadata = {
name: item.name,
version: item.version,
filename: '/tmp/package.tgz',
files: []
}
expect(extractPnpmPackRecord(`\u001B[32mlifecycle output\u001B[0m\n${JSON.stringify(metadata)}`, item)).toEqual(
metadata
)
expect(extractPnpmPackRecord(`\u001B[36mlifecycle output\u001B[0m${JSON.stringify(metadata)}`, item)).toEqual(
metadata
)
expect(extractPnpmPackRecord(`[prepack warning${JSON.stringify(metadata)}`, item)).toEqual(metadata)
expect(extractPnpmPackRecord(`lifecycle output\n${JSON.stringify([metadata])}\nmore lifecycle output`, item))
.toEqual(
metadata
)
expect(() => extractPnpmPackRecord(`${JSON.stringify(metadata)}\n${JSON.stringify(metadata)}`, item)).toThrow(
'exactly one documented JSON result'
)
expect(() => extractPnpmPackRecord('{"filename":"package.tgz"}', item)).toThrow(
'exactly one documented JSON result'
)
expect(() => extractPnpmPackRecord('{ '.repeat(10_000), item)).toThrow('incomplete JSON output')
})

it('freezes lifecycle-noisy source and alias tarballs, then publishes those exact bytes', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'npm-publish-guard-pack-'))
const outputDirectory = await mkdtemp(path.join(tmpdir(), 'npm-publish-guard-output-'))
const source = { name: 'source-package', version: '1.0.0', dir: directory, publishAliasFor: null }
const alias = { ...source, name: 'alias-package', publishAliasFor: 'source-package' }
const calls: string[][] = []
try {
await writeFile(
path.join(directory, 'package.json'),
JSON.stringify({
name: source.name,
version: source.version,
bin: { 'source-package': './cli.js' }
bin: { 'source-package': './cli.js' },
scripts: { prepack: 'node prepack.cjs' }
})
)
await writeFile(path.join(directory, 'cli.js'), 'console.log("ok")\n')
const frozen = await freezeApprovedTarballs({ items: [source, alias] })
expect(frozen.get(source.name)?.sha512).toHaveLength(128)
expect(frozen.get(alias.name)?.sha512).toHaveLength(128)
await writeFile(
path.join(directory, 'prepack.cjs'),
"process.stdout.write('\\u001B[32mnoisy lifecycle output\\u001B[0m[prepack warning')\n"
)
const frozen = await freezeApprovedTarballs({ items: [source, alias], outputDir: outputDirectory })
const canonicalOutputDirectory = await realpath(outputDirectory)
const sourceTarball = frozen.get(source.name)
const aliasTarball = frozen.get(alias.name)
expect(sourceTarball?.filePath?.startsWith(`${canonicalOutputDirectory}${path.sep}`)).toBe(true)
expect(aliasTarball?.filePath?.startsWith(`${canonicalOutputDirectory}${path.sep}`)).toBe(true)
expect(sourceTarball?.filePath).not.toBe(aliasTarball?.filePath)
expect(sourceTarball?.sha512).toHaveLength(128)
expect(aliasTarball?.sha512).toHaveLength(128)
await expect(executeFrozenPublish({
items: [source, alias],
approvedTarballs: frozen,
publishTag: 'rc',
dryRun: true,
preflightMetadata: new Map([[source.name, { versions: {} }], [alias.name, { versions: {} }]]),
fetchImpl: async () => new Response(null, { status: 404 }),
runCommand: (_command: string, args: string[]) => {
calls.push(args)
return { status: 0, stdout: '', stderr: '' }
}
})).resolves.toEqual({ attempts: [{ name: source.name, status: 0 }, { name: alias.name, status: 0 }] })
expect(calls.map(args => args[1])).toEqual([sourceTarball?.filePath, aliasTarball?.filePath])
} finally {
await rm(directory, { recursive: true, force: true })
await rm(outputDirectory, { recursive: true, force: true })
}
})

Expand Down
9 changes: 8 additions & 1 deletion scripts/npm-publish-guard.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export declare const npmRegistryPropagationAttempts: number
export declare const npmRegistryPropagationDelayMs: number
export declare const npmPublishAuthModes: Set<NpmPublishAuthMode>
export declare function redactNpmPublishSecrets(value: unknown, secrets?: string[]): string
export declare function extractPnpmPackRecord(
stdout: unknown,
item: { name: string; version: string }
): { name: string; version: string; filename: string; files: unknown[] }
export declare function evaluateNpmPublishMode(
input: {
mode: NpmPublishAuthMode
Expand Down Expand Up @@ -45,7 +49,10 @@ export declare function waitForNpmRegistryVersions(
}
): Promise<{ attemptsUsed: number }>
export declare function freezeApprovedTarballs(
input: { items: Array<{ name: string; version: string }> }
input: {
items: Array<{ name: string; version: string }>
outputDir?: string
}
): Promise<Map<string, NpmTarballDigest>>
export declare function preparePublishWorkspaceDependencies(
input: {
Expand Down
83 changes: 77 additions & 6 deletions scripts/npm-publish-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Buffer } from 'node:buffer'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
Expand Down Expand Up @@ -155,6 +155,80 @@ export async function proveOidcExchangesBeforePublish({ selectedItems, requestTo
return { exchangedIdentityCount: selectedItems.length }
}

const pnpmPackRecord = (value, item) => {
const records = Array.isArray(value) ? value : [value]
if (records.length !== 1) return null
const record = records[0]
if (
record == null || typeof record !== 'object' || record.name !== item.name || record.version !== item.version ||
typeof record.filename !== 'string' || !record.filename || !Array.isArray(record.files)
) return null
return record
}

const isPlausibleJsonRoot = (output, start) => {
const character = output[start]
const next = output[start + 1]
if (character === '{') return next === '"' || next === '}' || /\s/.test(next)
return character === '[' && (next === '{' || next === ']' || /\s/.test(next))
}

export function extractPnpmPackRecord(stdout, item) {
const output = String(stdout)
const records = []
for (let start = 0; start < output.length; start += 1) {
if (!isPlausibleJsonRoot(output, start)) continue
let depth = 0
let inString = false
let escaped = false
for (let end = start; end < output.length; end += 1) {
const character = output[end]
if (inString) {
if (escaped) escaped = false
else if (character === '\\') escaped = true
else if (character === '"') inString = false
continue
}
if (character === '"') {
inString = true
continue
}
if (character === '{' || character === '[') depth += 1
else if (character === '}' || character === ']') depth -= 1
if (depth !== 0) continue
try {
const record = pnpmPackRecord(JSON.parse(output.slice(start, end + 1)), item)
if (record != null) records.push(record)
} catch {
// Lifecycle output can contain braces. Only complete JSON documents matching pnpm's pack schema count.
}
start = end
break
}
if (depth !== 0) throw new Error(`${item.name} local pack returned incomplete JSON output.`)
}
if (records.length !== 1) {
throw new Error(`${item.name} local pack did not return exactly one documented JSON result.`)
}
return records[0]
}

const readPackedTarball = async ({ item, outputDir, filename }) => {
const candidatePath = path.resolve(outputDir, filename)
const [canonicalOutputDir, canonicalCandidatePath] = await Promise.all([realpath(outputDir), realpath(candidatePath)])
const relativePath = path.relative(canonicalOutputDir, canonicalCandidatePath)
if (
!relativePath || relativePath === '..' || relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath) ||
path.extname(canonicalCandidatePath) !== '.tgz'
) throw new Error(`${item.name} local pack returned a tarball outside the frozen output directory.`)
const candidateStats = await lstat(candidatePath)
if (!candidateStats.isFile() || candidateStats.isSymbolicLink()) {
throw new Error(`${item.name} local pack returned an unsafe tarball path.`)
}
return { bytes: await readFile(canonicalCandidatePath), filePath: canonicalCandidatePath }
}

const defaultPackPackage = async ({ item, outputDir }) => {
const restore = stagePublishAliasManifest(item)
try {
Expand All @@ -164,11 +238,8 @@ const defaultPackPackage = async ({ item, outputDir }) => {
stdio: 'pipe'
})
if (result.status !== 0) throw new Error(`${item.name} local pack failed.`)
const output = JSON.parse(String(result.stdout))
const filename = Array.isArray(output) ? output[0]?.filename : output?.filename
if (!filename) throw new Error(`${item.name} local pack did not return a filename.`)
const filePath = path.isAbsolute(filename) ? filename : path.join(outputDir, filename)
return { bytes: await readFile(filePath), filePath }
const { filename } = extractPnpmPackRecord(result.stdout, item)
return readPackedTarball({ item, outputDir, filename })
} finally {
restore?.()
}
Expand Down
Loading