Files
pnpm/resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts
Zoltan Kochan 963861cac1 perf(npm-resolver): layer abbreviated meta + attestation before full metadata in the minimumReleaseAge gate (#11704)
Follow-up to #11691 — item 2 from #11687, plus a related shortcut.

## What

When the `minimumReleaseAge` lockfile verification gate needs to know when a version was published, it used to fetch a multi-MB full metadata document per package just to read one timestamp. This PR replaces that single-step path with a four-layer lookup that pays the cheapest viable source first:

1. **Abbreviated metadata's `modified` field** — the resolver already fetches this for resolution. If the package as a whole hasn't been modified within the policy cutoff, every version it contains is at least that old; return `modified` as a conservative upper-bound and skip the rest of the chain.
2. **Local `FULL_META_DIR` mirror** — exact per-version times if a previous verification populated it.
3. **npm attestation endpoint** (`/-/npm/v1/attestations/<name>@<version>`) — a tens-of-KB Sigstore bundle whose Rekor inclusion time stands in for publish time. Wins on cold cache when the package was published with provenance.
4. **Full metadata fetch** — last resort.

## Why

The verification cache from #11691 made repeat installs against an unchanged lockfile effectively free. The remaining cost is the *first* verification on a fresh CI runner with no restored cache — particularly `pnpm install --frozen-lockfile`, where every locked package's publish timestamp has to be confirmed. Fetching the full metadata document for each package is wasteful when:

- The resolver has usually already cached abbreviated metadata, whose `modified` field alone is enough to clear stable packages (the common case).
- For recently-modified packages, the per-version attestation endpoint is orders of magnitude smaller than full metadata.

## How

### Abbreviated `modified` shortcut

`fetchFullMetadataCached` is refactored to share an internal helper with a new `fetchAbbreviatedMetadataCached`. Both do conditional GETs against their respective on-disk mirrors. On a non-frozen install the abbreviated mirror is already populated by the resolver, so the shortcut hits the local cache at headers-only cost. On `--frozen-lockfile` the fetch is still cheaper than full metadata.

If `Date.parse(modified) < cutoff`, return `modified` — it's an upper bound on every version's publish time in this package, and the verifier's `published < cutoff` check passes trivially.

### Attestation endpoint

`fetchAttestationPublishedAt` (new module) hits `/-/npm/v1/attestations/<name>@<version>`, parses the response, and reads the earliest `bundle.verificationMaterial.tlogEntries[].integratedTime` across the attestation bundles. That's the Rekor inclusion time — a couple of seconds after publish, well within tolerance for a policy that operates in minutes/hours/days. Returns `undefined` on 404 / network error / malformed body so the caller falls back.

### Per-install dedup

The lookup carries a `PublishedAtLookupContext` with four memo maps: abbreviated meta per (registry, name), local full meta per (registry, name), full meta network fetch per (registry, name), final published-at per (registry, name, version). Verifying many versions of the same package only pays the disk/network costs once.

## Trust model

**No Sigstore signature verification on the attestation path.** The trust model is identical to reading the registry's `time` field on the full metadata document — we already trust the registry to serve correct publish timestamps for the gate's purpose. The win is purely bandwidth.

Full Sigstore verification (Fulcio cert chain + Rekor inclusion proof) would harden the timestamp against a compromised registry. It pulls in the `sigstore` npm package and the TUF root — a separate dependency-surface discussion, parked as future work.

## Tests

- **13 unit tests** in `resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts`: ISO timestamp extraction, URL construction (scoped + unscoped), 404 / 5xx / network error / malformed JSON / missing fields → undefined, earliest-of-multiple-attestations, defensive number-as-integratedTime, auth header forwarding, trailing-slash normalization.
- Existing `minimumReleaseAge` + `verifyLockfileResolutions` integration suites (45 tests) still pass — the fallback chain preserves end-to-end behavior when the new shortcuts don't apply.
2026-05-17 15:54:01 +02:00

213 lines
6.6 KiB
TypeScript

import { describe, expect, test } from '@jest/globals'
import { fetchAttestationPublishedAt } from '../src/fetchAttestationPublishedAt.js'
type StubFetch = (url: string, opts?: unknown) => Promise<{
status: number
json: () => Promise<unknown>
}>
function makeFetchOpts (fetch: StubFetch) {
return {
fetch: fetch as never,
retry: { retries: 0 },
timeout: 10_000,
fetchWarnTimeoutMs: 10_000,
}
}
const REGISTRY = 'https://registry.npmjs.org/'
function attestationResponse (...integratedTimes: Array<string | number>): unknown {
return {
attestations: integratedTimes.map((t) => ({
predicateType: 'https://github.com/npm/attestation/tree/main/specs/publish/v0.1',
bundle: {
verificationMaterial: {
tlogEntries: [{ integratedTime: t }],
},
},
})),
}
}
describe('fetchAttestationPublishedAt', () => {
test('returns an ISO timestamp built from tlogEntries[].integratedTime', async () => {
// 1778583836 (Unix seconds) → 2026-05-12T05:43:56.000Z
const fetch: StubFetch = async () => ({
status: 200,
json: async () => attestationResponse('1778583836'),
})
const result = await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY }
)
expect(result).toBe(new Date(1778583836 * 1000).toISOString())
})
test('hits /-/npm/v1/attestations/<name>@<version> with a literal name@version spec', async () => {
const seenUrls: string[] = []
const fetch: StubFetch = async (url) => {
seenUrls.push(url)
return { status: 404, json: async () => null }
}
await fetchAttestationPublishedAt(makeFetchOpts(fetch), 'pnpm', '11.1.1', { registry: REGISTRY })
expect(seenUrls).toEqual(['https://registry.npmjs.org/-/npm/v1/attestations/pnpm@11.1.1'])
})
test('scoped package name passes through unencoded (slashes are path separators)', async () => {
const seenUrls: string[] = []
const fetch: StubFetch = async (url) => {
seenUrls.push(url)
return { status: 404, json: async () => null }
}
await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'@pnpm/exe',
'11.1.1',
{ registry: REGISTRY }
)
expect(seenUrls).toEqual(['https://registry.npmjs.org/-/npm/v1/attestations/@pnpm/exe@11.1.1'])
})
test('returns undefined when the registry has no attestations for the package (404)', async () => {
const fetch: StubFetch = async () => ({ status: 404, json: async () => null })
const result = await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY }
)
expect(result).toBeUndefined()
})
test('returns undefined on 5xx — caller falls back to full metadata', async () => {
const fetch: StubFetch = async () => ({ status: 503, json: async () => null })
const result = await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY }
)
expect(result).toBeUndefined()
})
test('returns undefined when the fetch itself throws (network error)', async () => {
const fetch: StubFetch = async () => {
throw new Error('ECONNREFUSED')
}
const result = await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY }
)
expect(result).toBeUndefined()
})
test('returns undefined when the body is malformed JSON', async () => {
const fetch: StubFetch = async () => ({
status: 200,
json: async () => {
throw new SyntaxError('bad')
},
})
const result = await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY }
)
expect(result).toBeUndefined()
})
test('returns undefined when the response has no attestations array', async () => {
const fetch: StubFetch = async () => ({ status: 200, json: async () => ({}) })
const result = await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY }
)
expect(result).toBeUndefined()
})
test('returns undefined when no tlogEntry carries a usable integratedTime', async () => {
const fetch: StubFetch = async () => ({
status: 200,
json: async () => ({
attestations: [{ bundle: { verificationMaterial: { tlogEntries: [{}] } } }],
}),
})
const result = await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY }
)
expect(result).toBeUndefined()
})
test('picks the earliest integratedTime across multiple attestations', async () => {
// SLSA provenance is signed slightly before npm publish attestation;
// earlier integratedTime is the conservative pick.
const fetch: StubFetch = async () => ({
status: 200,
json: async () => attestationResponse('1778583836', '1778583833'),
})
const result = await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY }
)
expect(result).toBe(new Date(1778583833 * 1000).toISOString())
})
test('accepts integratedTime as a number too (defensive against schema drift)', async () => {
const fetch: StubFetch = async () => ({
status: 200,
json: async () => attestationResponse(1778583836),
})
const result = await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY }
)
expect(result).toBe(new Date(1778583836 * 1000).toISOString())
})
test('forwards the auth header to the fetch call', async () => {
let seenAuth: string | undefined
const fetch: StubFetch = async (_url, opts) => {
seenAuth = (opts as { authHeaderValue?: string } | undefined)?.authHeaderValue
return { status: 404, json: async () => null }
}
await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: REGISTRY, authHeaderValue: 'Bearer secret' }
)
expect(seenAuth).toBe('Bearer secret')
})
test('strips a trailing slash on the registry URL', async () => {
const seenUrls: string[] = []
const fetch: StubFetch = async (url) => {
seenUrls.push(url)
return { status: 404, json: async () => null }
}
await fetchAttestationPublishedAt(
makeFetchOpts(fetch),
'pnpm',
'11.1.1',
{ registry: 'https://registry.npmjs.org/' }
)
expect(seenUrls[0]).toBe('https://registry.npmjs.org/-/npm/v1/attestations/pnpm@11.1.1')
})
})