fix: handle sha in extension image links (#15437)

* fix: handle sha in extension image links

using sha link is not working in "install custom extension..."

fixes https://github.com/podman-desktop/podman-desktop/issues/15434
Signed-off-by: Florent Benoit <fbenoit@redhat.com>
This commit is contained in:
Florent BENOIT 2025-12-24 09:45:25 +01:00 committed by GitHub
parent a3cea8665d
commit b0aa664265
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 159 additions and 5 deletions

View file

@ -374,6 +374,96 @@ describe('extractImageDataFromImageName', () => {
expect(nameAndTag.tag).toBe('latest');
expect(nameAndTag.name).toBe('level1/level2/level3/level4/myimage');
});
test('digest format on library image', () => {
const nameAndTag = imageRegistry.extractImageDataFromImageName(
'httpd@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
);
expect(nameAndTag.registry).toBe('index.docker.io');
expect(nameAndTag.registryURL).toBe('https://index.docker.io/v2');
expect(nameAndTag.tag).toBe('sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789');
expect(nameAndTag.name).toBe('library/httpd');
});
test('digest format on namespaced image', () => {
const nameAndTag = imageRegistry.extractImageDataFromImageName(
'foo/bar@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
);
expect(nameAndTag.registry).toBe('index.docker.io');
expect(nameAndTag.registryURL).toBe('https://index.docker.io/v2');
expect(nameAndTag.tag).toBe('sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789');
expect(nameAndTag.name).toBe('foo/bar');
});
test('digest format with explicit registry', () => {
const nameAndTag = imageRegistry.extractImageDataFromImageName(
'quay.io/org/image@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
);
expect(nameAndTag.registry).toBe('quay.io');
expect(nameAndTag.registryURL).toBe('https://quay.io/v2');
expect(nameAndTag.tag).toBe('sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789');
expect(nameAndTag.name).toBe('org/image');
});
test('digest format with localhost and port', () => {
const nameAndTag = imageRegistry.extractImageDataFromImageName(
'localhost:5000/myimage@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
);
expect(nameAndTag.registry).toBe('localhost:5000');
expect(nameAndTag.registryURL).toBe('https://localhost:5000/v2');
expect(nameAndTag.tag).toBe('sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789');
expect(nameAndTag.name).toBe('myimage');
});
test('tag and digest format on library image', () => {
const nameAndTag = imageRegistry.extractImageDataFromImageName(
'httpd:2.4@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
);
expect(nameAndTag.registry).toBe('index.docker.io');
expect(nameAndTag.registryURL).toBe('https://index.docker.io/v2');
expect(nameAndTag.tag).toBe('sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789');
expect(nameAndTag.name).toBe('library/httpd');
});
test('tag and digest format on namespaced image', () => {
const nameAndTag = imageRegistry.extractImageDataFromImageName(
'foo/bar:v1.0.0@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
);
expect(nameAndTag.registry).toBe('index.docker.io');
expect(nameAndTag.registryURL).toBe('https://index.docker.io/v2');
expect(nameAndTag.tag).toBe('sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789');
expect(nameAndTag.name).toBe('foo/bar');
});
test('tag and digest format with explicit registry (ghcr.io example)', () => {
const nameAndTag = imageRegistry.extractImageDataFromImageName(
'ghcr.io/podman-desktop/pd-extension-quadlet:0.11.0@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
);
expect(nameAndTag.registry).toBe('ghcr.io');
expect(nameAndTag.registryURL).toBe('https://ghcr.io/v2');
expect(nameAndTag.tag).toBe('sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef');
expect(nameAndTag.name).toBe('podman-desktop/pd-extension-quadlet');
});
test('tag and digest format with localhost and port', () => {
const nameAndTag = imageRegistry.extractImageDataFromImageName(
'localhost:5000/myimage:latest@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
);
expect(nameAndTag.registry).toBe('localhost:5000');
expect(nameAndTag.registryURL).toBe('https://localhost:5000/v2');
expect(nameAndTag.tag).toBe('sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789');
expect(nameAndTag.name).toBe('myimage');
});
test('tag and digest format with multi-level path', () => {
const nameAndTag = imageRegistry.extractImageDataFromImageName(
'quay.io/org/team/image:v2.1.0@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
);
expect(nameAndTag.registry).toBe('quay.io');
expect(nameAndTag.registryURL).toBe('https://quay.io/v2');
expect(nameAndTag.tag).toBe('sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210');
expect(nameAndTag.name).toBe('org/team/image');
});
});
test('expect getImageConfigLabels works', async () => {
@ -445,6 +535,53 @@ test('expect getManifestFromImageName works', async () => {
expect(manifest).toStrictEqual(imageRegistryManifestJson);
});
test('expect getManifestFromImageName works with digest', async () => {
const spyGetAuthInfo = vi.spyOn(imageRegistry, 'getAuthInfo');
spyGetAuthInfo.mockResolvedValue({ authUrl: 'http://foobar', scheme: 'bearer' });
const spyGetToken = vi.spyOn(imageRegistry, 'getToken');
spyGetToken.mockResolvedValue('12345');
const digest = 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
const handlers = [
http.get(`https://my-podman-desktop-fake-registry.io/v2/my/extension/manifests/${digest}`, () =>
HttpResponse.json(imageRegistryManifestJson),
),
];
server = setupServer(...handlers);
server.listen({ onUnhandledRequest: 'error' });
const manifest = await imageRegistry.getManifestFromImageName(
`my-podman-desktop-fake-registry.io/my/extension@${digest}`,
);
expect(manifest).toStrictEqual(imageRegistryManifestJson);
});
test('expect getManifestFromImageName works with tag and digest', async () => {
const spyGetAuthInfo = vi.spyOn(imageRegistry, 'getAuthInfo');
spyGetAuthInfo.mockResolvedValue({ authUrl: 'http://foobar', scheme: 'bearer' });
const spyGetToken = vi.spyOn(imageRegistry, 'getToken');
spyGetToken.mockResolvedValue('12345');
const digest = 'sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
const handlers = [
http.get(`https://ghcr.io/v2/podman-desktop/pd-extension-quadlet/manifests/${digest}`, () =>
HttpResponse.json(imageRegistryManifestJson),
),
];
server = setupServer(...handlers);
server.listen({ onUnhandledRequest: 'error' });
// Tag is ignored when digest is present, digest is used for manifest lookup
const manifest = await imageRegistry.getManifestFromImageName(
`ghcr.io/podman-desktop/pd-extension-quadlet:0.11.0@${digest}`,
);
expect(manifest).toStrictEqual(imageRegistryManifestJson);
});
test('expect downloadAndExtractImage works', async () => {
// need to mock the http request
const spyGetAuthInfo = vi.spyOn(imageRegistry, 'getAuthInfo');

View file

@ -393,13 +393,30 @@ export class ImageRegistry {
throw new Error(`Invalid image name: ${imageName}`);
}
// do we have a tag at the end with last
// Check if image is referenced by digest (@sha256:hash) instead of tag
// Format can be: name:tag, name@sha256:hash, or name:tag@sha256:hash
let tag = 'latest';
const lastColon = imageName.lastIndexOf(':');
const atIndex = imageName.indexOf('@');
const lastSlash = imageName.lastIndexOf('/');
if (lastColon !== -1 && lastColon > lastSlash) {
tag = imageName.substring(lastColon + 1);
imageName = imageName.substring(0, lastColon);
if (atIndex !== -1 && atIndex > lastSlash) {
// Image uses digest format: name@sha256:hash or name:tag@sha256:hash
// The digest is the authoritative reference
tag = imageName.substring(atIndex + 1);
imageName = imageName.substring(0, atIndex);
// Remove any tag that might be present before the @ (e.g., :0.11.0 in name:0.11.0@sha256:...)
const lastColon = imageName.lastIndexOf(':');
if (lastColon !== -1 && lastColon > lastSlash) {
imageName = imageName.substring(0, lastColon);
}
} else {
// Check for tag format: name:tag
const lastColon = imageName.lastIndexOf(':');
if (lastColon !== -1 && lastColon > lastSlash) {
tag = imageName.substring(lastColon + 1);
imageName = imageName.substring(0, lastColon);
}
}
let registry = '';