diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 51b3489a..f1a2c5ea 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -27,3 +27,18 @@ query-filters: - backend/src/utils/apiTokenFormat.ts - backend/src/routes/apiTokens.ts - backend/src/__tests__/** + + # registry-api.ts resolves image references parsed from admin-controlled + # compose files into Docker registry manifest URLs. CodeQL traces the file + # data into the outbound HTTPS request and flags it as a request-forgery + # risk. Sencho is single-tenant and self-hosted: the admin who writes the + # compose files owns the server, and specifying arbitrary registries is the + # intended behavior. probeManifestForRef guards against URL-injection + # characters in tag/repo components before constructing the URL; the + # remaining taint path is inherent to the product's design rather than a + # vulnerability. Excluding this file so the query still catches real SSRF + # from untrusted multi-tenant or external input elsewhere. + - exclude: + id: js/request-forgery + paths: + - backend/src/services/registry-api.ts diff --git a/backend/src/__tests__/image-update-service.test.ts b/backend/src/__tests__/image-update-service.test.ts index 6994f129..60f63521 100644 --- a/backend/src/__tests__/image-update-service.test.ts +++ b/backend/src/__tests__/image-update-service.test.ts @@ -13,7 +13,7 @@ const { mockGetSystemState, mockSetSystemState, mockAddNotificationHistory, mockDispatchAlert, mockGetStacks, mockGetStackContent, mockGetEnvContent, mockEnvExists, - mockGetAllContainers, mockGetGlobalSettings, + mockGetAllContainers, mockGetGlobalSettings, mockInspect, } = vi.hoisted(() => ({ mockGetAuthForRegistry: vi.fn().mockResolvedValue(null), mockGetStackUpdateStatus: vi.fn().mockReturnValue({}), @@ -30,6 +30,9 @@ const { mockEnvExists: vi.fn().mockResolvedValue(false), mockGetAllContainers: vi.fn().mockResolvedValue([]), mockGetGlobalSettings: vi.fn().mockReturnValue({ developer_mode: '0' }), + // Backs DockerController.getInstance().getDocker().getImage().inspect() for tests + // that exercise the real checkImage (rather than stubbing it) through checkNode. + mockInspect: vi.fn().mockResolvedValue({ RepoDigests: [] }), })); vi.mock('../services/RegistryService', () => ({ @@ -81,6 +84,7 @@ vi.mock('../services/DockerController', () => ({ default: { getInstance: () => ({ getAllContainers: mockGetAllContainers, + getDocker: () => ({ getImage: () => ({ inspect: mockInspect }) }), }), }, })); @@ -94,12 +98,12 @@ vi.mock('../services/NodeRegistry', () => ({ }, })); -// getRemoteDigestResult is module-scoped inside checkImage; mock it to drive the remote -// outcome while keeping the real parseImageRef / repoDigestMatchesRef. -const { mockGetRemoteDigestResult } = vi.hoisted(() => ({ mockGetRemoteDigestResult: vi.fn() })); +// compareLocalToRemoteTag is module-scoped inside checkImage; mock it to drive the +// comparison outcome while keeping the real parseImageRef / selectLocalRepoDigest. +const { mockCompareLocalToRemoteTag } = vi.hoisted(() => ({ mockCompareLocalToRemoteTag: vi.fn() })); vi.mock('../services/registry-api', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, getRemoteDigestResult: mockGetRemoteDigestResult }; + return { ...actual, compareLocalToRemoteTag: mockCompareLocalToRemoteTag }; }); // ── Re-export internal helpers via the module ───────────────────────── @@ -194,11 +198,13 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => { }); }); -// ── checkImage surfaces the remote-digest reason ─────────────────────── +// ── checkImage surfaces the comparison resolver's outcome ────────────── -describe('ImageUpdateService - checkImage surfaces the remote-digest reason', () => { +describe('ImageUpdateService - checkImage surfaces the comparison resolver outcome', () => { let service: ImageUpdateService; + const LOCAL_DIGEST = `sha256:${'a'.repeat(64)}`; + beforeEach(() => { vi.clearAllMocks(); (ImageUpdateService as any).instance = undefined; @@ -206,24 +212,110 @@ describe('ImageUpdateService - checkImage surfaces the remote-digest reason', () }); // One RepoDigest matching the ref so the local digest resolves and the flow reaches - // getRemoteDigestResult. + // compareLocalToRemoteTag. const dockerWithLocalDigest = (digest: string) => ({ getDocker: () => ({ - getImage: () => ({ inspect: vi.fn().mockResolvedValue({ RepoDigests: [`ghcr.io/linuxserver/radarr@${digest}`] }) }), + getImage: () => ({ inspect: vi.fn().mockResolvedValue({ + RepoDigests: [`ghcr.io/linuxserver/radarr@${digest}`], + Os: 'linux', + Architecture: 'amd64', + }) }), }), } as any); it('surfaces the specific failure reason (not a generic "unreachable") as the check error', async () => { - mockGetRemoteDigestResult.mockResolvedValue({ ok: false, reason: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' }); - const result = await service.checkImage(dockerWithLocalDigest('sha256:local'), 'ghcr.io/linuxserver/radarr:latest'); + mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'error', reason: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' }); + const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest'); expect(result).toEqual({ hasUpdate: false, error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' }); }); - it('reports an update when the resolved remote digest differs from the local one', async () => { - mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:remote' }); - const result = await service.checkImage(dockerWithLocalDigest('sha256:local'), 'ghcr.io/linuxserver/radarr:latest'); + it('reports an update when the comparison resolver classifies the remote as an update', async () => { + mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'update' }); + const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest'); expect(result).toEqual({ hasUpdate: true }); }); + + it('reports no update when the comparison resolver classifies the remote as a match', async () => { + mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' }); + const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest'); + expect(result).toEqual({ hasUpdate: false }); + }); + + it('passes the local digest, platform, and parsed ref through to the comparison resolver', async () => { + mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' }); + await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest'); + expect(mockCompareLocalToRemoteTag).toHaveBeenCalledWith( + LOCAL_DIGEST, + 'ghcr.io', + 'linuxserver/radarr', + 'latest', + { os: 'linux', architecture: 'amd64' }, + null, + ); + }); +}); + +// ── Multi-arch digest comparison persistence (end-to-end via checkNode) ─ + +describe('ImageUpdateService - multi-arch digest comparison persistence', () => { + const LOCAL_DIGEST = `sha256:${'a'.repeat(64)}`; + const COMPOSE = ` +services: + app: + image: ghcr.io/linuxserver/radarr:latest +`; + + const fakeDb = () => ({ + getStackUpdateStatus: mockGetStackUpdateStatus, + upsertStackUpdateStatus: mockUpsertStackUpdateStatus, + clearStackUpdateStatus: mockClearStackUpdateStatus, + recordStackCheckFailure: mockRecordStackCheckFailure, + getSystemState: mockGetSystemState, + setSystemState: mockSetSystemState, + addNotificationHistory: mockAddNotificationHistory, + }); + + beforeEach(() => { + vi.clearAllMocks(); + (ImageUpdateService as any).instance = undefined; + mockGetSystemState.mockReturnValue('1'); + mockGetStacks.mockResolvedValue(['stackA']); + mockGetStackContent.mockResolvedValue(COMPOSE); + mockGetAllContainers.mockResolvedValue([]); + mockEnvExists.mockResolvedValue(false); + mockGetAuthForRegistry.mockResolvedValue(null); + mockInspect.mockResolvedValue({ + RepoDigests: [`ghcr.io/linuxserver/radarr@${LOCAL_DIGEST}`], + Os: 'linux', + Architecture: 'amd64', + }); + }); + + it('clears a stored has_update=true after a successful child-manifest match (ok, no last_error, no notification)', async () => { + mockGetStackUpdateStatus.mockReturnValue({ stackA: true }); + mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' }); + const service = ImageUpdateService.getInstance(); + + await (service as any).checkNode(1, fakeDb()); + + expect(mockUpsertStackUpdateStatus).toHaveBeenCalledWith(1, 'stackA', false, expect.any(Number), 'ok', null); + expect(mockRecordStackCheckFailure).not.toHaveBeenCalled(); + expect(mockDispatchAlert).not.toHaveBeenCalled(); + }); + + it('preserves a stored has_update=true when the comparison resolver errors (fail-soft, no false negative)', async () => { + mockGetStackUpdateStatus.mockReturnValue({ stackA: true }); + mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'error', reason: 'Failed to classify remote manifest for ghcr.io/linuxserver/radarr:latest' }); + const service = ImageUpdateService.getInstance(); + + await (service as any).checkNode(1, fakeDb()); + + expect(mockRecordStackCheckFailure).toHaveBeenCalledWith( + 1, 'stackA', expect.stringContaining('Failed to classify remote manifest'), expect.any(Number), + ); + expect(mockUpsertStackUpdateStatus).not.toHaveBeenCalled(); + expect(mockDispatchAlert).not.toHaveBeenCalled(); + }); }); // ── Rate limiting ───────────────────────────────────────────────────── diff --git a/backend/src/__tests__/registry-api.test.ts b/backend/src/__tests__/registry-api.test.ts index 65d3eec9..ff03dae6 100644 --- a/backend/src/__tests__/registry-api.test.ts +++ b/backend/src/__tests__/registry-api.test.ts @@ -4,7 +4,8 @@ * getRemoteDigest's HEAD-first lookup with GET fallback, and getRemoteDigestResult's * status-to-reason mapping. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createHash } from 'crypto'; import { EventEmitter } from 'events'; // ── Configurable https mock ─────────────────────────────────────────────── @@ -12,23 +13,36 @@ import { EventEmitter } from 'events'; // mock routes by URL + method so each test controls the manifest response while // the token request always succeeds. -interface FakeResp { statusCode: number; headers: Record; body?: string; } +interface FakeResp { + statusCode: number; + headers: Record; + body?: string; + /** When set, emitted as separate data events (for UTF-8 chunk-boundary tests). */ + bodyChunks?: Buffer[]; +} const calls: { url: string; method: string }[] = []; let route: (url: string, method: string) => FakeResp; -function fakeRequest(url: string, options: { method?: string }, cb: (res: EventEmitter & { statusCode: number; headers: Record }) => void) { +function fakeRequest(url: string, options: { method?: string }, cb: (res: EventEmitter & { statusCode: number; headers: Record; destroy: () => void }) => void) { const method = options?.method ?? 'GET'; calls.push({ url, method }); const resp = route(url, method); - const res = Object.assign(new EventEmitter(), { statusCode: resp.statusCode, headers: resp.headers }); + // `destroy` is a no-op stub: httpGetCapped calls it when a response exceeds + // the streaming size cap, which the fake response otherwise lacks (a real + // http.IncomingMessage is a Readable stream and always has it). + const res = Object.assign(new EventEmitter(), { statusCode: resp.statusCode, headers: resp.headers, destroy: () => {} }); const req = Object.assign(new EventEmitter(), { setTimeout: () => {}, destroy: () => {}, end: () => { cb(res); queueMicrotask(() => { - if (resp.body) res.emit('data', Buffer.from(resp.body)); + if (resp.bodyChunks) { + for (const chunk of resp.bodyChunks) res.emit('data', chunk); + } else if (resp.body) { + res.emit('data', Buffer.from(resp.body)); + } res.emit('end'); }); }, @@ -39,7 +53,24 @@ function fakeRequest(url: string, options: { method?: string }, cb: (res: EventE vi.mock('https', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters)) } })); vi.mock('http', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters)) } })); -import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, listRegistryTagsResult, parseImageRef } from '../services/registry-api'; +import { + repoDigestMatchesRef, + getRemoteDigest, + getRemoteDigestResult, + getAuthToken, + listRegistryTagsResult, + parseImageRef, + selectLocalRepoDigest, + compareLocalToRemoteTag, + MANIFEST_CLASSIFICATION_CACHE_TTL_MS, + MANIFEST_INDEX_DESCRIPTOR_CAP, + MANIFEST_INDEX_MAX_DEPTH, +} from '../services/registry-api'; +import { CacheService } from '../services/CacheService'; + +beforeEach(() => { + CacheService.getInstance().flush(); +}); const TOKEN_BODY = JSON.stringify({ token: 'test-token' }); const REMOTE = 'sha256:remote000000000000000000000000000000000000000000000000000000'; @@ -225,6 +256,15 @@ describe('getRemoteDigestResult failure reasons', () => { expect(await get()).toEqual({ ok: false, reason: `Authentication failed for ${REF}` }); }); + it('succeeds from the digest header even when the manifest body is malformed (no index expansion on this path)', async () => { + route = (url, method) => tokenOk(url) ?? ( + method === 'HEAD' + ? { statusCode: 405, headers: {} } + : { statusCode: 200, headers: { 'docker-content-digest': REMOTE }, body: 'not-json-at-all{{{' } + ); + expect(await get()).toEqual({ ok: true, digest: REMOTE }); + }); + it('fails when both HEAD and GET are 200 but omit the digest header', async () => { route = (url, method) => tokenOk(url) ?? (method === 'HEAD' ? { statusCode: 200, headers: {} } : { statusCode: 200, headers: {} }); expect(await get()).toEqual({ ok: false, reason: `Registry returned no digest for ${REF}` }); @@ -317,3 +357,811 @@ describe('listRegistryTagsResult', () => { await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list response too large'); }); }); + +// ─── selectLocalRepoDigest ─────────────────────────────────────────────── + +describe('selectLocalRepoDigest', () => { + const parsed = (ref: string) => { + const p = parseImageRef(ref); + if (!p) throw new Error(`unparseable ${ref}`); + return p; + }; + const DIGEST_A = `sha256:${'a'.repeat(64)}`; + const DIGEST_B = `sha256:${'b'.repeat(64)}`; + + it('picks the entry matching the parsed ref among multiple valid digests', () => { + const repoDigests = [`redis@${DIGEST_B}`, `nginx@${DIGEST_A}`]; + expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBe(DIGEST_A); + }); + + it('falls back to the sole valid entry when nothing matches the ref', () => { + const repoDigests = [`ghcr.io/other/image@${DIGEST_A}`]; + expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBe(DIGEST_A); + }); + + it('returns null when multiple valid entries exist and none matches the ref', () => { + const repoDigests = [`redis@${DIGEST_A}`, `postgres@${DIGEST_B}`]; + expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBeNull(); + }); + + it('returns null for a truncated (non-64-hex) digest even as the sole entry', () => { + expect(selectLocalRepoDigest(['nginx@sha256:abc123'], parsed('nginx:latest'))).toBeNull(); + }); + + it('returns null for an entry with no @ separator', () => { + expect(selectLocalRepoDigest(['nginx:latest'], parsed('nginx:latest'))).toBeNull(); + }); + + it('returns null for an empty list', () => { + expect(selectLocalRepoDigest([], parsed('nginx:latest'))).toBeNull(); + }); + + it('ignores a malformed entry when picking among multiple, still finds the ref match', () => { + const repoDigests = ['nginx@sha256:tooshort', `nginx@${DIGEST_A}`]; + expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBe(DIGEST_A); + }); + + it('is case-insensitive for hex digit casing', () => { + const upper = `sha256:${'A'.repeat(64)}`; + expect(selectLocalRepoDigest([`nginx@${upper}`], parsed('nginx:latest'))).toBe(upper); + }); +}); + +// ─── compareLocalToRemoteTag ───────────────────────────────────────────── +// +// Reproduces and fixes the false-positive multi-arch update: a local +// RepoDigest can be a platform child manifest while the registry's tag +// resolves to the parent index digest. These tests drive the HEAD/GET +// transport and the index-expansion classification directly. + +describe('compareLocalToRemoteTag', () => { + const REGISTRY = 'registry-1.docker.io'; + const REPO = 'someorg/someapp'; + const TAG = 'latest'; + const MANIFEST_URL_TAG = `https://${REGISTRY}/v2/${REPO}/manifests/${TAG}`; + const manifestDigestUrl = (digest: string, repo: string = REPO) => `https://${REGISTRY}/v2/${repo}/manifests/${digest}`; + + const CHILD_AMD64 = `sha256:${'c'.repeat(64)}`; + const CHILD_ARM64 = `sha256:${'b'.repeat(64)}`; + const SINGLE_DIGEST = `sha256:${'d'.repeat(64)}`; + + const AMD64 = { os: 'linux', architecture: 'amd64' }; + const ARM64 = { os: 'linux', architecture: 'arm64' }; + + const INDEX_CONTENT_TYPE = 'application/vnd.oci.image.index.v1+json'; + + interface DescriptorSpec { + digest: string; + os: string; + architecture: string; + variant?: string; + annotations?: Record; + } + + function indexBody(entries: DescriptorSpec[]): string { + return JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: entries.map((e) => ({ + digest: e.digest, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + platform: { os: e.os, architecture: e.architecture, ...(e.variant ? { variant: e.variant } : {}) }, + ...(e.annotations ? { annotations: e.annotations } : {}), + })), + }); + } + + function contentDigest(body: string): string { + return `sha256:${createHash('sha256').update(body, 'utf8').digest('hex')}`; + } + + /** HEAD the tag for `primary`, then serve digest-pinned GET bodies (or custom FakeResp). */ + function routePrimaryDigest( + primary: string, + digests: Record, + ): (url: string, method: string) => FakeResp { + return (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': primary, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (method === 'GET') { + for (const [digest, payload] of Object.entries(digests)) { + if (url !== manifestDigestUrl(digest)) continue; + return typeof payload === 'string' + ? { statusCode: 200, headers: { 'docker-content-digest': digest }, body: payload } + : payload; + } + } + return { statusCode: 500, headers: {} }; + }; + } + + const STANDARD_INDEX_BODY = indexBody([ + { digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' }, + { digest: CHILD_ARM64, os: 'linux', architecture: 'arm64' }, + ]); + // Content-addressed: digest-pinned GETs verify sha256(body) === requested digest. + const INDEX_DIGEST = contentDigest(STANDARD_INDEX_BODY); + + beforeEach(() => { + calls.length = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns match with no expansion GET when the local digest equals the primary digest from HEAD', async () => { + route = (url, method) => tokenOk(url) ?? ( + method === 'HEAD' + ? { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } } + : { statusCode: 500, headers: {} } + ); + const result = await compareLocalToRemoteTag(INDEX_DIGEST, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1); + }); + + it('expands the index with a single digest-pinned GET and matches a runnable child descriptor', async () => { + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([ + { url: MANIFEST_URL_TAG, method: 'HEAD' }, + { url: manifestDigestUrl(INDEX_DIGEST), method: 'GET' }, + ]); + }); + + it('never re-fetches the mutable tag: the expansion GET targets the primary digest from HEAD, not a second tag lookup', async () => { + const DIVERGED_DIGEST = `sha256:${'e'.repeat(64)}`; + let tagCallCount = 0; + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG) { + tagCallCount++; + if (method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + // A hypothetical second tag lookup racing to a different digest; the + // resolver must never issue this call once a primary digest is set. + return { statusCode: 200, headers: { 'docker-content-digest': DIVERGED_DIGEST } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + expect(tagCallCount).toBe(1); + }); + + it('reports update without a body fetch when the mismatched primary has a known single-manifest media type', async () => { + route = (url, method) => tokenOk(url) ?? ( + method === 'HEAD' + ? { statusCode: 200, headers: { 'docker-content-digest': SINGLE_DIGEST, 'content-type': 'application/vnd.docker.distribution.manifest.v2+json' } } + : { statusCode: 500, headers: {} } + ); + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'update' }); + expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1); + }); + + it('classifies from the HEAD-fallback GET body without a second expansion request (HEAD 405)', async () => { + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG) { + if (method === 'HEAD') return { statusCode: 405, headers: {} }; + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64); + expect(result).toEqual({ kind: 'match' }); + expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([ + { url: MANIFEST_URL_TAG, method: 'HEAD' }, + { url: MANIFEST_URL_TAG, method: 'GET' }, + ]); + }); + + it('returns an error with no tag retry when the digest-pinned GET 404s on a cold cache', async () => { + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 404, headers: {} }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + expect(calls.filter((c) => c.url === MANIFEST_URL_TAG)).toHaveLength(1); + }); + + it('does not cache a rejected classification as success: a repeat comparison retries the fetch', async () => { + let digestGetCount = 0; + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + digestGetCount++; + return { statusCode: 500, headers: {} }; + } + return { statusCode: 500, headers: {} }; + }; + const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(first.kind).toBe('error'); + const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(second.kind).toBe('error'); + expect(digestGetCount).toBe(2); + }); + + it('falls back to the stale cached classification when the digest GET fails after the cache entry expires', async () => { + vi.useFakeTimers(); + let digestGetCount = 0; + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + digestGetCount++; + if (digestGetCount === 1) { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + } + return { statusCode: 500, headers: {} }; + }; + + const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(first).toEqual({ kind: 'match' }); + + await vi.advanceTimersByTimeAsync(MANIFEST_CLASSIFICATION_CACHE_TTL_MS + 1000); + + const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(second).toEqual({ kind: 'match' }); + expect(digestGetCount).toBe(2); + }); + + it('reuses the cached classification for a second comparison of the same primary digest (no second GET)', async () => { + let digestGetCount = 0; + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + digestGetCount++; + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64); + expect(digestGetCount).toBe(1); + }); + + it('deduplicates concurrent comparisons for the same primary digest into one classification fetch', async () => { + let digestGetCount = 0; + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + digestGetCount++; + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const [a, b] = await Promise.all([ + compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64), + compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64), + ]); + expect(a).toEqual({ kind: 'match' }); + expect(b).toEqual({ kind: 'match' }); + expect(digestGetCount).toBe(1); + }); + + it('misses the cache when the primary digest changes (new manifest, new immutable key)', async () => { + const INDEX_BODY_2 = indexBody([{ digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' }]); + const INDEX_DIGEST_2 = contentDigest(INDEX_BODY_2); + let headDigest = INDEX_DIGEST; + let digestGetCount = 0; + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': headDigest, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + digestGetCount++; + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + if (url === manifestDigestUrl(INDEX_DIGEST_2) && method === 'GET') { + digestGetCount++; + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST_2 }, body: INDEX_BODY_2 }; + } + return { statusCode: 500, headers: {} }; + }; + const first = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64); + expect(first).toEqual({ kind: 'match' }); + + headDigest = INDEX_DIGEST_2; + const second = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64); + expect(second).toEqual({ kind: 'update' }); + expect(digestGetCount).toBe(2); + }); + + it('isolates the classification cache by registry and repository, not just the digest', async () => { + let digestGetCount = 0; + const routeFor = (repo: string) => (url: string, method: string): FakeResp => { + const token = tokenOk(url); + if (token) return token; + const tagUrl = `https://${REGISTRY}/v2/${repo}/manifests/${TAG}`; + if (url === tagUrl && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST, repo) && method === 'GET') { + digestGetCount++; + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + + route = routeFor(REPO); + await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(digestGetCount).toBe(1); + + route = routeFor('otherorg/otherapp'); + await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, 'otherorg/otherapp', TAG, AMD64); + expect(digestGetCount).toBe(2); + }); + + it('matches a local digest against any runnable descriptor sharing os+architecture across variants', async () => { + const VARIANT_V6 = `sha256:${'1'.repeat(64)}`; + const VARIANT_V7 = `sha256:${'2'.repeat(64)}`; + const body = indexBody([ + { digest: VARIANT_V6, os: 'linux', architecture: 'arm', variant: 'v6' }, + { digest: VARIANT_V7, os: 'linux', architecture: 'arm', variant: 'v7' }, + ]); + const primary = contentDigest(body); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': primary, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(primary) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': primary }, body }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(VARIANT_V7, REGISTRY, REPO, TAG, { os: 'linux', architecture: 'arm' }); + expect(result).toEqual({ kind: 'match' }); + }); + + it('ignores unknown/unknown placeholder descriptors and attestation-manifest annotations', async () => { + const ATTESTATION_UNKNOWN = `sha256:${'3'.repeat(64)}`; + const ATTESTATION_ANNOTATED = `sha256:${'4'.repeat(64)}`; + const body = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [ + { digest: CHILD_AMD64, mediaType: 'application/vnd.oci.image.manifest.v1+json', platform: { os: 'linux', architecture: 'amd64' } }, + { digest: ATTESTATION_UNKNOWN, mediaType: 'application/vnd.oci.image.manifest.v1+json', platform: { os: 'unknown', architecture: 'unknown' } }, + { + digest: ATTESTATION_ANNOTATED, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + platform: { os: 'linux', architecture: 'amd64' }, + annotations: { 'vnd.docker.reference.type': 'attestation-manifest' }, + }, + ], + }); + const primary = contentDigest(body); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': primary, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(primary) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': primary }, body }; + } + return { statusCode: 500, headers: {} }; + }; + + // A local digest equal to the filtered-out annotated-attestation entry must + // never match, since that descriptor is dropped before the membership check. + const filtered = await compareLocalToRemoteTag(ATTESTATION_ANNOTATED, REGISTRY, REPO, TAG, AMD64); + expect(filtered).toEqual({ kind: 'update' }); + + // The real platform descriptor still matches normally (cache hit reuses the + // same parsed classification from the previous call). + const realMatch = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(realMatch).toEqual({ kind: 'match' }); + }); + + it('errors when the raw manifests array exceeds the 256-descriptor cap', async () => { + const manifests = Array.from({ length: MANIFEST_INDEX_DESCRIPTOR_CAP + 1 }, (_, i) => ({ + digest: `sha256:${i.toString(16).padStart(64, '0')}`, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + platform: { os: 'linux', architecture: 'amd64' }, + })); + const body = JSON.stringify({ schemaVersion: 2, manifests }); + const primary = contentDigest(body); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': primary, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(primary) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': primary }, body }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + }); + + it('errors when the digest-pinned manifest body is not valid JSON', async () => { + const badBody = 'not json{{'; + const primary = contentDigest(badBody); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': primary, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(primary) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': primary }, body: badBody }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + }); + + it('errors when the digest-pinned GET returns a docker-content-digest that disagrees with the requested digest', async () => { + const WRONG_DIGEST = `sha256:${'5'.repeat(64)}`; + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': WRONG_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('mismatched digest') }); + }); + + it('errors when the digest-pinned GET omits docker-content-digest and the body hash does not match', async () => { + // Body looks like a matching index for the local digest, but its sha256 is not INDEX_DIGEST. + const fakeMatchBody = indexBody([{ digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' }]); + expect(contentDigest(fakeMatchBody)).not.toBe(INDEX_DIGEST); + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 200, headers: {}, body: fakeMatchBody }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('does not match the requested digest') }); + }); + + it('errors without a speculative match when the local platform os/architecture is unknown', async () => { + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, { os: '', architecture: '' }); + expect(result.kind).toBe('error'); + }); + + it('rejects a truncated local digest as an error, never as a speculative update', async () => { + route = () => ({ statusCode: 500, headers: {} }); // must never be reached + const result = await compareLocalToRemoteTag('sha256:tooshort', REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'error', reason: 'Local digest is malformed or truncated' }); + expect(calls).toHaveLength(0); + }); + + it('degrades to an uncached comparison (not an error) when the classification cache is at capacity', async () => { + const cache = CacheService.getInstance(); + for (let i = 0; i < 1000; i++) cache.set(`filler:${i}`, { kind: 'single' as const }, 3_600_000); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + let digestGetCount = 0; + route = (url, method) => { + const token = tokenOk(url); + if (token) return token; + if (url === MANIFEST_URL_TAG && method === 'HEAD') { + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }; + } + if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') { + digestGetCount++; + return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY }; + } + return { statusCode: 500, headers: {} }; + }; + + const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + + expect(first).toEqual({ kind: 'match' }); + expect(second).toEqual({ kind: 'match' }); + expect(digestGetCount).toBe(2); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('preserves UTF-8 integrity when a multibyte code point is split across data events', async () => { + // 🚢 is F0 9F 9A A2; split after two bytes so naïve per-chunk toString corrupts it. + const ship = '🚢'; + const body = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + annotations: { 'org.opencontainers.image.description': `QA-${ship}-manifest` }, + manifests: [ + { + digest: CHILD_AMD64, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + platform: { os: 'linux', architecture: 'amd64' }, + }, + ], + }); + const primary = contentDigest(body); + const raw = Buffer.from(body, 'utf8'); + const shipOffset = raw.indexOf(Buffer.from(ship, 'utf8')); + expect(shipOffset).toBeGreaterThan(0); + const splitAt = shipOffset + 2; + + route = routePrimaryDigest(primary, { + [primary]: { + statusCode: 200, + headers: { 'docker-content-digest': primary }, + bodyChunks: [raw.subarray(0, splitAt), raw.subarray(splitAt)], + }, + }); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + }); + + it('matches a leaf under a nested index via digest-pinned recursion', async () => { + const nestedBody = indexBody([ + { digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' }, + { digest: CHILD_ARM64, os: 'linux', architecture: 'arm64' }, + ]); + const nestedDigest = contentDigest(nestedBody); + const outerBody = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [ + { + digest: nestedDigest, + mediaType: INDEX_CONTENT_TYPE, + platform: { os: 'linux', architecture: 'amd64' }, + }, + ], + }); + const outerDigest = contentDigest(outerBody); + + route = routePrimaryDigest(outerDigest, { + [outerDigest]: outerBody, + [nestedDigest]: nestedBody, + }); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + expect(calls.filter((c) => c.method === 'GET' && c.url.includes('/manifests/'))).toHaveLength(2); + }); + + it('returns error (not update) when a nested index digest is unavailable', async () => { + const nestedDigest = `sha256:${'a'.repeat(64)}`; + const outerBody = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [ + { digest: nestedDigest, mediaType: INDEX_CONTENT_TYPE }, + { + digest: CHILD_ARM64, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + platform: { os: 'linux', architecture: 'arm64' }, + }, + ], + }); + const outerDigest = contentDigest(outerBody); + + route = routePrimaryDigest(outerDigest, { + [outerDigest]: outerBody, + [nestedDigest]: { statusCode: 404, headers: {} }, + }); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + }); + + it('matches a runnable descriptor that omits optional platform metadata by exact digest', async () => { + const body = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [ + { digest: CHILD_AMD64, mediaType: 'application/vnd.oci.image.manifest.v1+json' }, + { + digest: CHILD_ARM64, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + platform: { os: 'linux', architecture: 'arm64' }, + }, + ], + }); + const primary = contentDigest(body); + route = routePrimaryDigest(primary, { [primary]: body }); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + }); + + it('returns error (not update) when index nesting exceeds the depth limit', async () => { + // Build a chain primary -> d1 -> d2 -> ... of length MANIFEST_INDEX_MAX_DEPTH + 1. + const bodies: { digest: string; body: string }[] = []; + const leafBody = indexBody([{ digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' }]); + let leafDigest = contentDigest(leafBody); + bodies.push({ digest: leafDigest, body: leafBody }); + + for (let depth = 0; depth < MANIFEST_INDEX_MAX_DEPTH; depth++) { + const parentBody = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [{ digest: leafDigest, mediaType: INDEX_CONTENT_TYPE }], + }); + const parentDigest = contentDigest(parentBody); + bodies.push({ digest: parentDigest, body: parentBody }); + leafDigest = parentDigest; + } + const outerDigest = leafDigest; + const digests: Record = {}; + for (const { digest, body } of bodies) digests[digest] = body; + + route = routePrimaryDigest(outerDigest, digests); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + if (result.kind === 'error') { + expect(result.reason).toMatch(/depth/i); + } + }); + + it('returns error (not update) for a descriptor with an unrecognized media type', async () => { + const body = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [ + { + digest: CHILD_AMD64, + mediaType: 'application/vnd.example.weird-manifest+json', + platform: { os: 'linux', architecture: 'amd64' }, + }, + ], + }); + const primary = contentDigest(body); + route = routePrimaryDigest(primary, { [primary]: body }); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + }); + + it('still expands a nested index descriptor even when its platform is unknown/unknown', async () => { + const nestedBody = indexBody([{ digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' }]); + const nestedDigest = contentDigest(nestedBody); + const outerBody = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [ + { + digest: nestedDigest, + mediaType: INDEX_CONTENT_TYPE, + platform: { os: 'unknown', architecture: 'unknown' }, + }, + ], + }); + const outerDigest = contentDigest(outerBody); + route = routePrimaryDigest(outerDigest, { + [outerDigest]: outerBody, + [nestedDigest]: nestedBody, + }); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result).toEqual({ kind: 'match' }); + }); + + it('returns error (not update) for a nested descriptor with a non-digest string', async () => { + const outerBody = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [ + { digest: '../other/manifests/evil', mediaType: INDEX_CONTENT_TYPE }, + { + digest: CHILD_ARM64, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + platform: { os: 'linux', architecture: 'arm64' }, + }, + ], + }); + const outerDigest = contentDigest(outerBody); + route = routePrimaryDigest(outerDigest, { [outerDigest]: outerBody }); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + expect(calls.some((c) => c.url.includes('../') || c.url.includes('/evil'))).toBe(false); + }); + + it('returns error (not update) when a descriptor is missing its digest', async () => { + const body = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + manifests: [ + { mediaType: 'application/vnd.oci.image.manifest.v1+json', platform: { os: 'linux', architecture: 'amd64' } }, + ], + }); + const primary = contentDigest(body); + route = routePrimaryDigest(primary, { [primary]: body }); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + }); + + it('returns error (not update) when Content-Type is an index but the body has no manifests array', async () => { + const body = JSON.stringify({ + schemaVersion: 2, + mediaType: INDEX_CONTENT_TYPE, + config: { digest: CHILD_AMD64, mediaType: 'application/vnd.oci.image.config.v1+json', size: 1 }, + layers: [], + }); + const primary = contentDigest(body); + route = routePrimaryDigest(primary, { [primary]: body }); + + const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64); + expect(result.kind).toBe('error'); + }); +}); diff --git a/backend/src/__tests__/update-preview-service.test.ts b/backend/src/__tests__/update-preview-service.test.ts index 267aa855..1907143e 100644 --- a/backend/src/__tests__/update-preview-service.test.ts +++ b/backend/src/__tests__/update-preview-service.test.ts @@ -7,7 +7,15 @@ import { buildSummary, isMovingTag, type ComputePreviewDeps, + type LocalDigestInfo, } from '../services/UpdatePreviewService'; +import type { DigestComparisonResult } from '../services/registry-api'; + +const PLATFORM = { os: 'linux', architecture: 'amd64' }; + +function localDigest(digest: string | null): LocalDigestInfo { + return { digest, platform: PLATFORM }; +} describe('parseSemverTag', () => { it('parses bare semver', () => { @@ -87,18 +95,18 @@ describe('computeSemverBump', () => { function makeDeps(overrides: Partial = {}): ComputePreviewDeps { return { getCredentials: vi.fn().mockResolvedValue(null), - getLocalDigest: vi.fn().mockResolvedValue(null), - getRemoteDigest: vi.fn().mockResolvedValue(null), + getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)), + compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'not configured' } satisfies DigestComparisonResult), listRegistryTags: vi.fn().mockResolvedValue([]), ...overrides, }; } describe('computeImagePreview', () => { - it('reports no update when digests match and no higher tag exists', async () => { + it('reports no update when the comparison resolver matches and no higher tag exists', async () => { const deps = makeDeps({ - getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'), - getRemoteDigest: vi.fn().mockResolvedValue('sha256:aaa'), + getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')), + compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }), listRegistryTags: vi.fn().mockResolvedValue(['1.2.3']), }); const result = await computeImagePreview('web', 'nginx:1.2.3', deps); @@ -107,10 +115,10 @@ describe('computeImagePreview', () => { expect(result.next_tag).toBeNull(); }); - it('reports digest rebuild as patch when tag is unchanged but digest differs', async () => { + it('reports digest rebuild as patch when tag is unchanged but the resolver classifies an update', async () => { const deps = makeDeps({ - getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'), - getRemoteDigest: vi.fn().mockResolvedValue('sha256:bbb'), + getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')), + compareDigest: vi.fn().mockResolvedValue({ kind: 'update' }), listRegistryTags: vi.fn().mockResolvedValue([]), }); const result = await computeImagePreview('web', 'nginx:latest', deps); @@ -122,8 +130,8 @@ describe('computeImagePreview', () => { it('reports higher semver tag when available', async () => { const deps = makeDeps({ - getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'), - getRemoteDigest: vi.fn().mockResolvedValue('sha256:aaa'), + getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')), + compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }), listRegistryTags: vi.fn().mockResolvedValue(['27.1.4', '27.1.5', '27.2.0']), }); const result = await computeImagePreview('engine', 'docker.io/library/docker:27.1.4', deps); @@ -134,14 +142,61 @@ describe('computeImagePreview', () => { it('flags major semver jumps', async () => { const deps = makeDeps({ - getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'), - getRemoteDigest: vi.fn().mockResolvedValue('sha256:aaa'), + getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')), + compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }), listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '2.0.0']), }); const result = await computeImagePreview('db', 'postgres:1.2.3', deps); expect(result.next_tag).toBe('2.0.0'); expect(result.semver_bump).toBe('major'); }); + + it('fails soft (no digest-based update) when the comparison resolver errors, but a higher tag still surfaces', async () => { + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')), + compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }), + listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '1.2.4']), + }); + const result = await computeImagePreview('web', 'nginx:1.2.3', deps); + expect(result.has_update).toBe(true); + expect(result.next_tag).toBe('1.2.4'); + expect(result.semver_bump).toBe('patch'); + }); + + it('fails soft to no-update when the comparison resolver errors and no higher tag exists', async () => { + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')), + compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }), + listRegistryTags: vi.fn().mockResolvedValue([]), + }); + const result = await computeImagePreview('web', 'nginx:1.2.3', deps); + expect(result.has_update).toBe(false); + expect(result.next_tag).toBeNull(); + expect(result.semver_bump).toBe('none'); + }); + + it('never calls the comparison resolver when no local digest is resolvable', async () => { + const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' }); + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)), + compareDigest, + listRegistryTags: vi.fn().mockResolvedValue([]), + }); + const result = await computeImagePreview('web', 'nginx:1.2.3', deps); + expect(compareDigest).not.toHaveBeenCalled(); + expect(result.has_update).toBe(false); + }); + + it('passes the local digest, tag, and platform through to the comparison resolver', async () => { + const compareDigest = vi.fn().mockResolvedValue({ kind: 'match' }); + const deps = makeDeps({ + getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')), + compareDigest, + listRegistryTags: vi.fn().mockResolvedValue([]), + }); + await computeImagePreview('web', 'ghcr.io/linuxserver/radarr:latest', deps); + expect(compareDigest).toHaveBeenCalledWith('sha256:aaa', 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null); + }); }); describe('buildSummary', () => { diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 719b9016..f0e46b92 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -8,7 +8,7 @@ import { RegistryService } from './RegistryService'; import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; import { sanitizeNotificationMessage } from '../utils/notificationMessage'; -import { parseImageRef, getRemoteDigestResult, repoDigestMatchesRef } from './registry-api'; +import { parseImageRef, selectLocalRepoDigest, compareLocalToRemoteTag } from './registry-api'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { sanitizeForLog } from '../utils/safeLog'; @@ -766,8 +766,9 @@ export class ImageUpdateService { console.log(`[ImageUpdateService] ${imageRef}: credentials ${credentials ? 'found' : 'none'}`); } - // Get local digest from RepoDigests - let localDigest: string | null = null; + // Get local digest and platform from RepoDigests / Os+Architecture + let localDigest: string | null; + let platform: { os: string; architecture: string }; try { const inspect = await withTimeout(docker.getDocker().getImage(imageRef).inspect(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'inspect'); const repoDigests: string[] = inspect.RepoDigests ?? []; @@ -776,15 +777,8 @@ export class ImageUpdateService { // status does not apply. if (repoDigests.length === 0) return { hasUpdate: false, notCheckable: true }; - for (const rd of repoDigests) { - if (!rd.includes('@sha256:')) continue; - const [, digest] = rd.split('@'); - - if (repoDigestMatchesRef(rd, parsed) || repoDigests.length === 1) { - localDigest = digest; - break; - } - } + localDigest = selectLocalRepoDigest(repoDigests, parsed); + platform = { os: inspect.Os, architecture: inspect.Architecture }; } catch { return { hasUpdate: false, error: `Failed to inspect local image "${imageRef}"` }; } @@ -795,17 +789,13 @@ export class ImageUpdateService { return { hasUpdate: false, error: `Could not resolve a local registry digest for "${imageRef}"` }; } - const remote = await getRemoteDigestResult(parsed.registry, parsed.repo, parsed.tag, credentials); - if (!remote.ok) { - return { hasUpdate: false, error: remote.reason }; + const comparison = await compareLocalToRemoteTag(localDigest, parsed.registry, parsed.repo, parsed.tag, platform, credentials); + if (comparison.kind === 'error') { + return { hasUpdate: false, error: comparison.reason }; } - const remoteDigest = remote.digest; - const hasUpdate = localDigest !== remoteDigest; - console.log( - `[ImageUpdateService] ${imageRef}: ` + - `local=${localDigest.slice(0, 27)}... remote=${remoteDigest.slice(0, 27)}... update=${hasUpdate}` - ); + const hasUpdate = comparison.kind === 'update'; + console.log(`[ImageUpdateService] ${imageRef}: local=${localDigest.slice(0, 27)}... update=${hasUpdate}`); return { hasUpdate }; } } diff --git a/backend/src/services/UpdatePreviewService.ts b/backend/src/services/UpdatePreviewService.ts index 9f9232c7..c2854baf 100644 --- a/backend/src/services/UpdatePreviewService.ts +++ b/backend/src/services/UpdatePreviewService.ts @@ -10,9 +10,12 @@ import { } from './ImageUpdateService'; import { parseImageRef, - getRemoteDigest, + selectLocalRepoDigest, + compareLocalToRemoteTag, listRegistryTags, + type ParsedRef, type RegistryCredentials, + type DigestComparisonResult, } from './registry-api'; export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown'; @@ -161,9 +164,14 @@ async function loadStackImages( return extractServiceImagesFromCompose(composeContent, merged); } +export interface LocalDigestInfo { + digest: string | null; + platform: { os: string; architecture: string }; +} + export interface ComputePreviewDeps { - getLocalDigest: (imageRef: string) => Promise; - getRemoteDigest: typeof getRemoteDigest; + getLocalDigest: (imageRef: string, parsed: ParsedRef) => Promise; + compareDigest: typeof compareLocalToRemoteTag; listRegistryTags: typeof listRegistryTags; getCredentials: (registry: string) => Promise; } @@ -187,15 +195,19 @@ export async function computeImagePreview( const credentials = await deps.getCredentials(parsed.registry); - // Digest-based: is a new build of the SAME tag available? - const [localDigest, remoteDigest] = await Promise.all([ - deps.getLocalDigest(imageRef), - deps.getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials), + // Digest-based: is a new build of the SAME tag available? A comparison error + // (network failure, malformed manifest) fails soft: it never claims a + // digest-based update, it only skips it. + const localInfo = await deps.getLocalDigest(imageRef, parsed); + const [comparison, tags] = await Promise.all([ + localInfo.digest + ? deps.compareDigest(localInfo.digest, parsed.registry, parsed.repo, parsed.tag, localInfo.platform, credentials) + : Promise.resolve({ kind: 'error', reason: 'No local registry digest available' }), + deps.listRegistryTags(parsed.registry, parsed.repo, credentials), ]); - const digestUpdate = Boolean(localDigest && remoteDigest && localDigest !== remoteDigest); + const digestUpdate = comparison.kind === 'update'; // Tag-based: is a higher semver tag available? - const tags = await deps.listRegistryTags(parsed.registry, parsed.repo, credentials); const nextTag = findNextTag(parsed.tag, tags); const hasUpdate = digestUpdate || nextTag !== null; @@ -297,20 +309,16 @@ export class UpdatePreviewService { const docker = DockerController.getInstance(nodeId); const deps: ComputePreviewDeps = { getCredentials: (registry) => RegistryService.getInstance().getAuthForRegistry(registry), - getRemoteDigest, + compareDigest: compareLocalToRemoteTag, listRegistryTags, - getLocalDigest: async (imageRef: string) => { + getLocalDigest: async (imageRef: string, parsed: ParsedRef): Promise => { try { const inspect = await docker.getDocker().getImage(imageRef).inspect(); const repoDigests: string[] = inspect.RepoDigests ?? []; - for (const rd of repoDigests) { - if (!rd.includes('@sha256:')) continue; - const [, digest] = rd.split('@'); - return digest; - } - return null; + const digest = selectLocalRepoDigest(repoDigests, parsed); + return { digest, platform: { os: inspect.Os, architecture: inspect.Architecture } }; } catch { - return null; + return { digest: null, platform: { os: '', architecture: '' } }; } }, }; diff --git a/backend/src/services/registry-api.ts b/backend/src/services/registry-api.ts index 474b6769..2f93b04d 100644 --- a/backend/src/services/registry-api.ts +++ b/backend/src/services/registry-api.ts @@ -1,6 +1,9 @@ import https from 'https'; import http from 'http'; +import crypto from 'crypto'; import { sanitizeForLog } from '../utils/safeLog'; +import { getErrorMessage } from '../utils/errors'; +import { CacheService } from './CacheService'; export interface ParsedRef { registry: string; @@ -93,6 +96,72 @@ export function httpGet( return httpRequest(url, 'GET', headers, timeoutMs); } +export interface CappedHttpResult { + statusCode: number; + headers: Record; + /** Raw response bytes. Empty when truncated. Decoded only after integrity checks. */ + bodyBytes: Buffer; + /** True when the response exceeded the cap and was aborted mid-stream. */ + truncated: boolean; +} + +/** + * GET with a hard cap on the accumulated response body, aborting the stream + * as soon as more than `capBytes` has arrived rather than accumulating an + * unbounded body and checking its size afterward. Used for manifest bodies + * fetched for index-expansion classification, where a hostile or + * misbehaving registry could otherwise return an arbitrarily large payload. + * + * Chunks are kept as Buffers and concatenated once. Decoding per chunk would + * corrupt multibyte UTF-8 sequences that straddle TCP boundaries and break + * content-addressed digest verification. + */ +export function httpGetCapped( + url: string, + headers: Record, + capBytes: number, + timeoutMs = 10000, +): Promise { + return new Promise((resolve, reject) => { + const lib = url.startsWith('https:') ? https : http; + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + }; + const req = lib.request(url, { method: 'GET', headers }, (res) => { + const chunks: Buffer[] = []; + let received = 0; + const cappedResult = (bodyBytes: Buffer, truncated: boolean): CappedHttpResult => ({ + statusCode: res.statusCode ?? 0, + headers: res.headers as Record, + bodyBytes, + truncated, + }); + res.on('data', (chunk: Buffer) => { + if (settled) return; + received += chunk.length; + if (received > capBytes) { + finish(() => resolve(cappedResult(Buffer.alloc(0), true))); + res.destroy(); + return; + } + chunks.push(chunk); + }); + res.on('end', () => finish(() => resolve(cappedResult(Buffer.concat(chunks, received), false)))); + res.on('error', (err) => finish(() => reject(err))); + }); + req.on('error', (err) => finish(() => reject(err))); + req.setTimeout(timeoutMs, () => { + const err = new Error('Request timed out'); + req.destroy(err); + finish(() => reject(err)); + }); + req.end(); + }); +} + export async function getAuthToken( registry: string, repo: string, @@ -170,6 +239,29 @@ export function repoDigestMatchesRef(repoDigest: string, parsed: ParsedRef): boo && parsedName.repo === parsed.repo; } +const SHA256_DIGEST_RE = /^sha256:[0-9a-f]{64}$/i; + +/** + * Deterministic local RepoDigest selection shared by the scanner and the + * update preview: the first entry whose repository matches the parsed + * image ref, else the sole remaining valid entry, else null. A truncated + * or malformed digest (not a complete "sha256:" + 64 hex chars) is never + * selected, so a corrupted RepoDigests entry surfaces as "could not + * resolve" rather than a false match or update. + */ +export function selectLocalRepoDigest(repoDigests: string[], parsed: ParsedRef): string | null { + const valid = repoDigests + .map((entry) => { + const at = entry.indexOf('@'); + return at === -1 ? null : { entry, digest: entry.slice(at + 1) }; + }) + .filter((e): e is { entry: string; digest: string } => e !== null && SHA256_DIGEST_RE.test(e.digest)); + + const matched = valid.find((e) => repoDigestMatchesRef(e.entry, parsed)); + if (matched) return matched.digest; + return valid.length === 1 ? valid[0].digest : null; +} + /** Outcome of a remote-digest lookup: the digest, or a human-readable reason it failed. */ export type RemoteDigestResult = | { ok: true; digest: string } @@ -195,22 +287,47 @@ function manifestFailureReason(statusCode: number, ref: string, headers: HttpRes return `Registry returned status ${statusCode} for ${ref}`; } +function firstHeaderValue(v: string | string[] | undefined): string | undefined { + return Array.isArray(v) ? v[0] : v; +} + +const MANIFEST_EXPANSION_BODY_CAP_BYTES = 1024 * 1024; // 1 MiB streaming abort + +interface ManifestProbeSuccess { + digest: string; + contentType: string | undefined; + /** Raw body from the GET fallback on HEAD 405/501/missing-digest, or null when a HEAD 200 with a digest header was all that was needed. */ + body: Buffer | null; + /** + * Accept + optional Bearer token used for this probe. Reused for a same-repo + * digest-pinned expansion GET so it does not re-authenticate. Never returned + * from an exported function. + */ + authHeaders: Record; +} + +type ManifestProbeOutcome = + | { ok: true; result: ManifestProbeSuccess } + | { ok: false; reason: string }; + /** - * Resolve the remote manifest digest for an image, returning either the digest or the - * reason the lookup failed. Same HEAD-first/GET-fallback transport as before (HEAD - * returns docker-content-digest without transferring the body, so it does not draw down - * Docker Hub's anonymous pull-rate budget the way a GET can); only the failure handling - * is richer. A 401/403/404/429/5xx HEAD reports its specific reason without a GET retry, - * since the bearer token is fetched up-front, so a 401 here is a real auth failure rather - * than a token-scope challenge to retry. + * Shared HEAD-first / GET-fallback manifest lookup for a tag or digest reference. + * Owns auth, the HEAD request, the GET-on-405/501-or-missing-digest fallback (bounded + * to {@link MANIFEST_EXPANSION_BODY_CAP_BYTES}), and digest/content-type extraction. + * Never parses or classifies a manifest body; that is the comparison resolver's job. + * Both the public no-expansion digest lookup ({@link getRemoteDigestResult}) and the + * comparison resolver ({@link compareLocalToRemoteTag}) call this so neither duplicates + * the transport or auth-fallback logic. A 401/403/404/429/5xx HEAD reports its specific + * reason without a GET retry, since the bearer token is fetched up-front, so a 401 here + * is a real auth failure rather than a token-scope challenge to retry. */ -export async function getRemoteDigestResult( +async function probeManifestForRef( registry: string, repo: string, - tag: string, - credentials?: RegistryCredentials | null, -): Promise { - const ref = `${registry}/${repo}:${tag}`; + tagOrDigest: string, + credentials: RegistryCredentials | null | undefined, + ref: string, +): Promise { try { // Auth transport failures used to collapse to null inside getAuthToken. // Tag listing now needs those errors to propagate (REGISTRY_UPSTREAM), so @@ -227,23 +344,55 @@ export async function getRemoteDigestResult( sanitizeForLog(cause), ); } - const headers: Record = { Accept: MANIFEST_ACCEPT }; - if (token) headers['Authorization'] = `Bearer ${token}`; - const url = `https://${registry}/v2/${repo}/manifests/${tag}`; + // Reject tag/repo components with characters that could alter the + // URL structure. Docker tags are restricted to [a-zA-Z0-9._-] per the + // OCI distribution spec; / ? # \ and null bytes are never valid. Repo + // path segments are [a-z0-9]+ separator . _ -; a .. segment is never + // valid and would enable path traversal. The image ref originates from + // an admin-controlled compose file, so this guard is defense-in-depth: + // the admin already has code execution on the host. + if (/[/?#\\]/.test(tagOrDigest) || tagOrDigest.includes('\0')) { + return { ok: false, reason: `Invalid tag "${sanitizeForLog(tagOrDigest)}" for ${ref}` }; + } + if (/\.\./.test(repo)) { + return { ok: false, reason: `Invalid repository path "${sanitizeForLog(repo)}" for ${ref}` }; + } - const head = await httpRequest(url, 'HEAD', headers); + const authHeaders: Record = { Accept: MANIFEST_ACCEPT }; + if (token) authHeaders['Authorization'] = `Bearer ${token}`; + const url = `https://${registry}/v2/${repo}/manifests/${tagOrDigest}`; + + const head = await httpRequest(url, 'HEAD', authHeaders); if (head.statusCode === 200) { const digest = head.headers['docker-content-digest']; - if (typeof digest === 'string') return { ok: true, digest }; + if (typeof digest === 'string') { + return { + ok: true, + result: { digest, contentType: firstHeaderValue(head.headers['content-type']), body: null, authHeaders }, + }; + } // 200 without the digest header: fall through to GET to read it from there. } else if (head.statusCode !== 405 && head.statusCode !== 501) { return { ok: false, reason: manifestFailureReason(head.statusCode, ref, head.headers) }; } - const res = await httpRequest(url, 'GET', headers); + const res = await httpGetCapped(url, authHeaders, MANIFEST_EXPANSION_BODY_CAP_BYTES); if (res.statusCode === 200) { const digest = res.headers['docker-content-digest']; - if (typeof digest === 'string') return { ok: true, digest }; + if (typeof digest === 'string') { + return { + ok: true, + result: { + digest, + contentType: firstHeaderValue(res.headers['content-type']), + // A truncated body cannot be classified; treat it as absent so a + // caller that needs it (the comparison resolver) re-fetches by + // digest and hits the same oversize condition explicitly. + body: res.truncated ? null : res.bodyBytes, + authHeaders, + }, + }; + } // 200 on both HEAD and GET but no digest header: a spec-violating registry. return { ok: false, reason: `Registry returned no digest for ${ref}` }; } @@ -262,6 +411,24 @@ export async function getRemoteDigestResult( } } +/** + * Resolve the remote manifest digest for an image, returning either the digest or the + * reason the lookup failed. Delegates to {@link probeManifestForRef}: success is + * determined solely by a valid docker-content-digest header, even if the body (when one + * was fetched) turns out to be malformed. No index expansion happens on this path; it is + * shared with {@link isSenchoVersionPublished} in version-check.ts. + */ +export async function getRemoteDigestResult( + registry: string, + repo: string, + tag: string, + credentials?: RegistryCredentials | null, +): Promise { + const ref = `${registry}/${repo}:${tag}`; + const probe = await probeManifestForRef(registry, repo, tag, credentials, ref); + return probe.ok ? { ok: true, digest: probe.result.digest } : { ok: false, reason: probe.reason }; +} + /** * Digest-or-null view of {@link getRemoteDigestResult} for callers that only need the * digest and treat any failure as "unknown" (e.g. the update-preview tag/digest diff). @@ -276,6 +443,367 @@ export async function getRemoteDigest( return result.ok ? result.digest : null; } +// ─── Multi-arch comparison resolver ───────────────────────────────────────── +// +// Fixes the false-positive multi-arch update: a local RepoDigest can be a +// platform child manifest (e.g. the linux/amd64 manifest) while the registry's +// tag resolves to the parent index/manifest-list digest. A naive +// `localDigest !== remoteDigest` then reports an update even though the +// platform content is current. compareLocalToRemoteTag expands the index +// (once per immutable digest, cached 24h) and checks membership instead. + +interface ManifestPlatformDescriptor { + digest: string; + os: string; + architecture: string; + variant?: string; +} + +type ManifestClassification = + | { kind: 'single' } + | { + kind: 'index'; + descriptors: ManifestPlatformDescriptor[]; + /** Leaf digests with no platform metadata; matched by exact digest membership only. */ + exactDigests: string[]; + }; + +/** Result of comparing a local image digest to the registry's current manifest for a tag. */ +export type DigestComparisonResult = + | { kind: 'match' } + | { kind: 'update' } + | { kind: 'error'; reason: string }; + +export const MANIFEST_CLASSIFICATION_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +export const MANIFEST_INDEX_DESCRIPTOR_CAP = 256; +/** Max nested index documents in a chain (primary + nested). Exceeding this fails closed. */ +export const MANIFEST_INDEX_MAX_DEPTH = 3; + +const MANIFEST_CLASSIFICATION_CACHE_NAMESPACE = 'img-upd-idx'; + +/** Media types that are never an index/manifest-list: a mismatch against one of these is a definite update, no body fetch needed. */ +const SINGLE_MANIFEST_MEDIA_TYPES = new Set([ + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.docker.distribution.manifest.v1+json', + 'application/vnd.oci.image.manifest.v1+json', +]); + +const INDEX_MANIFEST_MEDIA_TYPES = new Set([ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', +]); + +/** + * Cache key for a validated manifest classification. Namespaced for + * CacheService stats, keyed by the immutable primary digest (so a changed + * manifest is a cache miss, never stale data), and hashes the repository + * component so a capacity-cap warning log never leaks a raw compose image + * string. + */ +function manifestClassificationCacheKey(registry: string, repo: string, primaryDigest: string): string { + const repoHash = crypto.createHash('sha256').update(repo).digest('hex'); + return `${MANIFEST_CLASSIFICATION_CACHE_NAMESPACE}:${canonicalRegistry(registry)}/${repoHash}@${primaryDigest}`; +} + +/** One parsed index document before nested digests are expanded. */ +interface IndexParseSlice { + kind: 'slice'; + descriptors: ManifestPlatformDescriptor[]; + exactDigests: string[]; + nestedDigests: string[]; +} + +function indexSliceSize(slice: IndexParseSlice): number { + return slice.descriptors.length + slice.exactDigests.length + slice.nestedDigests.length; +} + +/** + * Parse one index/manifest-list body into platform descriptors, exact-digest + * leaf candidates (no platform), and nested index digests to fetch. Throws on + * malformed JSON, an oversize descriptor array, or a non-attestation descriptor + * whose media type is neither a known leaf nor a known index (fail closed so + * compare never treats incomplete classification as a definite update). + */ +function parseIndexBody(body: string): { kind: 'single' } | IndexParseSlice { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + throw new Error('Manifest body is not valid JSON'); + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('Manifest body is not a JSON object'); + } + const rawManifests = (parsed as { manifests?: unknown }).manifests; + if (!Array.isArray(rawManifests)) { + // No manifests array: a single-platform manifest (image config + layers), not an index. + return { kind: 'single' }; + } + if (rawManifests.length > MANIFEST_INDEX_DESCRIPTOR_CAP) { + throw new Error(`Manifest index has ${rawManifests.length} descriptors, exceeding the ${MANIFEST_INDEX_DESCRIPTOR_CAP} cap`); + } + + const descriptors: ManifestPlatformDescriptor[] = []; + const exactDigests: string[] = []; + const nestedDigests: string[] = []; + + for (const entry of rawManifests) { + if (!entry || typeof entry !== 'object') { + throw new Error('Manifest index has a malformed descriptor entry'); + } + const e = entry as Record; + const digest = typeof e.digest === 'string' && e.digest.length > 0 ? e.digest : null; + if (!digest) { + throw new Error('Manifest index descriptor is missing a digest'); + } + if (!SHA256_DIGEST_RE.test(digest)) { + throw new Error('Manifest index descriptor has a malformed digest'); + } + + const annotations = e.annotations as Record | undefined; + if (annotations?.['vnd.docker.reference.type'] === 'attestation-manifest') continue; + + const mediaType = typeof e.mediaType === 'string' ? e.mediaType : ''; + if (!mediaType) { + throw new Error('Manifest index descriptor is missing a media type'); + } + // Nested indexes must be queued before unknown/unknown filtering: OCI allows + // platform on index descriptors, and skipping them would yield a false update. + if (INDEX_MANIFEST_MEDIA_TYPES.has(mediaType)) { + nestedDigests.push(digest); + continue; + } + if (!SINGLE_MANIFEST_MEDIA_TYPES.has(mediaType)) { + throw new Error(`Manifest index has an unrecognized descriptor media type (${mediaType})`); + } + + const platform = e.platform as Record | undefined; + const os = platform && typeof platform.os === 'string' ? platform.os : null; + const architecture = platform && typeof platform.architecture === 'string' ? platform.architecture : null; + if (os === 'unknown' && architecture === 'unknown') continue; + + if (os && architecture) { + const variant = platform && typeof platform.variant === 'string' ? platform.variant : undefined; + descriptors.push(variant ? { digest, os, architecture, variant } : { digest, os, architecture }); + } else { + // OCI allows platform to be omitted on a runnable descriptor. Keep the + // digest for exact membership; do not invent a platform match. + exactDigests.push(digest); + } + } + + return { kind: 'slice', descriptors, exactDigests, nestedDigests }; +} + +/** Content-addressable digest of raw response bytes (`sha256:` + hex). */ +function contentDigestOfBytes(buf: Buffer): string { + return `sha256:${crypto.createHash('sha256').update(buf).digest('hex')}`; +} + +/** + * Fetch a manifest by its immutable digest (never a mutable tag) for + * index-expansion classification, reusing the auth headers from the tag + * probe rather than re-authenticating. Throws on any transport failure, a + * mismatched docker-content-digest header, a body whose sha256 does not + * equal the requested digest, or a truncated (oversize) body, so a bad + * fetch can never resolve as a cacheable classification. + * Returns verified raw bytes; callers decode to UTF-8 only after this check. + */ +async function fetchManifestBytesByDigest( + registry: string, + repo: string, + digest: string, + authHeaders: Record, + ref: string, +): Promise { + if (!SHA256_DIGEST_RE.test(digest)) { + throw new Error(`Manifest digest is malformed for ${ref}`); + } + const url = `https://${registry}/v2/${repo}/manifests/${digest}`; + const res = await httpGetCapped(url, authHeaders, MANIFEST_EXPANSION_BODY_CAP_BYTES); + if (res.statusCode !== 200) { + throw new Error(manifestFailureReason(res.statusCode, ref, res.headers)); + } + if (res.truncated) { + throw new Error(`Manifest at digest for ${ref} exceeded ${MANIFEST_EXPANSION_BODY_CAP_BYTES} bytes`); + } + const returned = res.headers['docker-content-digest']; + if (typeof returned === 'string' && returned !== digest) { + throw new Error(`Registry returned a mismatched digest for ${ref}`); + } + // Always verify the raw body. Trusting only the response header would + // skip integrity when the header is absent and would accept a + // header/body pair that a cache or proxy fabricated. + if (contentDigestOfBytes(res.bodyBytes) !== digest) { + throw new Error(`Registry response body does not match the requested digest for ${ref}`); + } + return res.bodyBytes; +} + +/** + * Flatten an index (and nested indexes) into platform + exact-digest membership + * lists. Digest-pinned only; never re-GETs a mutable tag. Depth, visited-digest, + * and per-index descriptor caps fail closed as thrown errors. + */ +async function resolveIndexClassification( + primaryBody: string, + primaryDigest: string, + registry: string, + repo: string, + authHeaders: Record, + ref: string, + contentType: string | undefined, +): Promise { + const first = parseIndexBody(primaryBody); + if (first.kind === 'single') { + // Index Content-Type with a non-index body is incomplete classification. + // Fail closed rather than treating the mismatch as a definite update. + if (contentType && INDEX_MANIFEST_MEDIA_TYPES.has(contentType)) { + throw new Error(`Manifest Content-Type is an image index but the body has no manifests array for ${ref}`); + } + return first; + } + + const descriptors: ManifestPlatformDescriptor[] = [...first.descriptors]; + const exactDigests = new Set(first.exactDigests); + const visited = new Set([primaryDigest]); + const queue: { digest: string; depth: number }[] = first.nestedDigests.map((digest) => ({ digest, depth: 1 })); + let totalDescriptors = indexSliceSize(first); + + while (queue.length > 0) { + const item = queue.shift(); + if (!item) break; + const { digest, depth } = item; + if (visited.has(digest)) continue; + if (depth >= MANIFEST_INDEX_MAX_DEPTH) { + throw new Error(`Manifest index nesting exceeds the depth limit of ${MANIFEST_INDEX_MAX_DEPTH} for ${ref}`); + } + visited.add(digest); + + const nestedBytes = await fetchManifestBytesByDigest(registry, repo, digest, authHeaders, ref); + const nested = parseIndexBody(nestedBytes.toString('utf8')); + if (nested.kind === 'single') { + // A digest advertised as an index media type resolved to a non-index body. + throw new Error(`Nested manifest at ${digest} is not an image index`); + } + totalDescriptors += indexSliceSize(nested); + if (totalDescriptors > MANIFEST_INDEX_DESCRIPTOR_CAP) { + throw new Error(`Manifest index expansion exceeds the ${MANIFEST_INDEX_DESCRIPTOR_CAP} descriptor cap`); + } + descriptors.push(...nested.descriptors); + for (const d of nested.exactDigests) exactDigests.add(d); + for (const nestedDigest of nested.nestedDigests) { + if (!visited.has(nestedDigest)) { + queue.push({ digest: nestedDigest, depth: depth + 1 }); + } + } + } + + return { kind: 'index', descriptors, exactDigests: [...exactDigests] }; +} + +/** + * Classify the manifest at `primaryDigest`, using CacheService (24h TTL, + * stale-on-error for a same-key expired entry) so repeated periodic scans do + * not re-fetch the same immutable manifest body. A known single-manifest + * Content-Type short-circuits to `{ kind: 'single' }` without a body fetch. + * A cache-cap refusal (CacheService.set logs and refuses, but still returns + * the computed value) degrades to an uncached comparison, not an error. + */ +async function classifyManifest( + registry: string, + repo: string, + primaryDigest: string, + contentType: string | undefined, + probeBody: Buffer | null, + authHeaders: Record, + ref: string, +): Promise { + const cacheKey = manifestClassificationCacheKey(registry, repo, primaryDigest); + const cache = CacheService.getInstance(); + + if (contentType && SINGLE_MANIFEST_MEDIA_TYPES.has(contentType)) { + const classification: ManifestClassification = { kind: 'single' }; + cache.set(cacheKey, classification, MANIFEST_CLASSIFICATION_CACHE_TTL_MS); + return classification; + } + + return cache.getOrFetch(cacheKey, MANIFEST_CLASSIFICATION_CACHE_TTL_MS, async () => { + // If the fallback GET already returned a body, classify that only when + // its content digest matches the primary digest from the probe. Never + // re-fetch the floating tag. + let bodyBytes: Buffer; + if (probeBody !== null) { + if (contentDigestOfBytes(probeBody) !== primaryDigest) { + throw new Error(`Registry response body does not match the requested digest for ${ref}`); + } + bodyBytes = probeBody; + } else { + bodyBytes = await fetchManifestBytesByDigest(registry, repo, primaryDigest, authHeaders, ref); + } + return resolveIndexClassification( + bodyBytes.toString('utf8'), + primaryDigest, + registry, + repo, + authHeaders, + ref, + contentType, + ); + }); +} + +/** + * Compare a local image digest to the registry's current manifest for a tag. + * `platform` is the local image's Os/Architecture (from `docker image inspect`), + * required to safely match against an index's platform descriptors; without it, + * an index mismatch is an error rather than a speculative match. Never retries + * against the mutable tag once a primary digest is established: classification + * always targets that digest. + */ +export async function compareLocalToRemoteTag( + localDigest: string, + registry: string, + repo: string, + tag: string, + platform: { os: string; architecture: string }, + credentials?: RegistryCredentials | null, +): Promise { + if (!SHA256_DIGEST_RE.test(localDigest)) { + return { kind: 'error', reason: 'Local digest is malformed or truncated' }; + } + + const ref = `${registry}/${repo}:${tag}`; + const probe = await probeManifestForRef(registry, repo, tag, credentials, ref); + if (!probe.ok) return { kind: 'error', reason: probe.reason }; + + const { digest: primaryDigest, contentType, body, authHeaders } = probe.result; + if (!SHA256_DIGEST_RE.test(primaryDigest)) { + return { kind: 'error', reason: `Registry returned a malformed digest for ${ref}` }; + } + if (localDigest === primaryDigest) return { kind: 'match' }; + + let classification: ManifestClassification; + try { + classification = await classifyManifest(registry, repo, primaryDigest, contentType, body, authHeaders, ref); + } catch (e) { + return { kind: 'error', reason: getErrorMessage(e, `Failed to classify remote manifest for ${ref}`) }; + } + + if (classification.kind === 'single') return { kind: 'update' }; + + if (classification.exactDigests.includes(localDigest)) return { kind: 'match' }; + + if (!platform.os || !platform.architecture) { + return { kind: 'error', reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` }; + } + + const isMember = classification.descriptors.some( + (d) => d.os === platform.os && d.architecture === platform.architecture && d.digest === localDigest, + ); + return isMember ? { kind: 'match' } : { kind: 'update' }; +} + export type TagListCode = | 'REGISTRY_UNAUTHORIZED' | 'REGISTRY_FORBIDDEN'