fix(image-updates): treat multi-arch child digests as up to date (#1641)

* fix(image-updates): treat multi-arch child digests as up to date

Floating tags like redis:8-alpine can store a platform child digest locally
while the registry tag resolves to the parent index. Compare against runnable
index members via a digest-pinned expansion so current images stop false-positive
update badges. Fixes #1630.

* fix(image-updates): preserve UTF-8 in capped GET and fail closed on nested indexes

Accumulate raw Buffer chunks before hashing or decoding so multibyte UTF-8
cannot corrupt content digests. Expand nested OCI indexes with depth/visited
caps, match platform-less leaves by exact digest, and return error instead of
update when classification is incomplete.

* fix: prefer-const lint error in registry-api test

* fix(image-updates): align multi-arch checkNode tests with 2-arg signature

After rebasing onto main (#1640), checkNode no longer takes nodeName. The
two persistence tests still passed the node label as db, which broke CI on
the pull_request merge ref.

* fix(image-updates): guard tag/repo components before registry URL construction, dismiss CodeQL false positive

Add defense-in-depth validation in probeManifestForRef that rejects tag
strings containing URL-injection characters (/ ? # \ null) and repo paths
with .. segments before they reach the outbound HTTPS request. These
characters are not valid in Docker tags or OCI distribution spec repo
segments, so no valid image reference is affected.

Exclude js/request-forgery on registry-api.ts via codeql-config.yml.
Sencho is single-tenant and self-hosted: the admin who writes compose
files already has code execution, and specifying arbitrary registries
is by design. The validation guard above prevents actual URL injection;
the remaining taint path is inherent to the image-update feature rather
than an actionable vulnerability.

Closes CodeQL alerts #531 and #532.
This commit is contained in:
Anso
2026-07-17 08:47:22 -04:00
committed by GitHub
parent 25586fc8ab
commit 66ec4ebdd2
7 changed files with 1626 additions and 90 deletions
+15
View File
@@ -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
@@ -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<typeof import('../services/registry-api')>();
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 ─────────────────────────────────────────────────────
+854 -6
View File
@@ -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<string, string>; body?: string; }
interface FakeResp {
statusCode: number;
headers: Record<string, string>;
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<string, string> }) => void) {
function fakeRequest(url: string, options: { method?: string }, cb: (res: EventEmitter & { statusCode: number; headers: Record<string, string>; 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<typeof fakeRequest>)) } }));
vi.mock('http', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
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<string, string>;
}
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<string, string | FakeResp>,
): (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<string, string> = {};
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');
});
});
@@ -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> = {}): 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', () => {
+11 -21
View File
@@ -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 };
}
}
+26 -18
View File
@@ -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<string | null>;
getRemoteDigest: typeof getRemoteDigest;
getLocalDigest: (imageRef: string, parsed: ParsedRef) => Promise<LocalDigestInfo>;
compareDigest: typeof compareLocalToRemoteTag;
listRegistryTags: typeof listRegistryTags;
getCredentials: (registry: string) => Promise<RegistryCredentials | null>;
}
@@ -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<DigestComparisonResult>({ 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<LocalDigestInfo> => {
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: '' } };
}
},
};
+547 -19
View File
@@ -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<string, string | string[] | undefined>;
/** 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<string, string>,
capBytes: number,
timeoutMs = 10000,
): Promise<CappedHttpResult> {
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<string, string | string[] | undefined>,
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<string, string>;
}
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<RemoteDigestResult> {
const ref = `${registry}/${repo}:${tag}`;
tagOrDigest: string,
credentials: RegistryCredentials | null | undefined,
ref: string,
): Promise<ManifestProbeOutcome> {
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<string, string> = { 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<string, string> = { 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<RemoteDigestResult> {
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<string, unknown>;
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<string, unknown> | 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<string, unknown> | 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<string, string>,
ref: string,
): Promise<Buffer> {
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<string, string>,
ref: string,
contentType: string | undefined,
): Promise<ManifestClassification> {
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<string>(first.exactDigests);
const visited = new Set<string>([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<string, string>,
ref: string,
): Promise<ManifestClassification> {
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<DigestComparisonResult> {
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'