mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 16:16:41 +00:00
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:
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user