Skip to content

Commit 077241f

Browse files
committed
feat: initial centralized image reference parser
1 parent 05f3319 commit 077241f

File tree

2 files changed

+297
-0
lines changed

2 files changed

+297
-0
lines changed

lib/image-reference.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Centralized image reference parsing for OCI image names.
3+
* Uses OCI distribution-style regexes to parse name, registry, tag, and digest.
4+
*/
5+
6+
// Full reference: optional registry, repository path, optional tag, optional digest.
7+
// Capture groups: 1 = name (repo path including optional registry), 2 = tag, 3 = digest.
8+
const imageReferencePattern = String.raw`^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\[(?:[a-fA-F0-9:]+)\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][a-fA-F0-9]{32,}))?$`;
9+
const imageReferenceRegex = new RegExp(imageReferencePattern);
10+
11+
// Registry prefix only. Requires '.' or localhost to distinguish registry from repository.
12+
// Capture group 1 = registry hostname (no trailing '/' or '@').
13+
const imageRegistryPattern = String.raw`^((?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+|\[(?:[a-fA-F0-9:]+)\]|localhost)(?::[0-9]+)?)(?:/|@)`;
14+
const imageRegistryRegex = new RegExp(imageRegistryPattern);
15+
16+
export interface ParsedImageReference {
17+
/** Repository path (e.g. nginx, library/nginx) */
18+
repository: string;
19+
/** Registry hostname (e.g. gcr.io, registry-1.docker.io); undefined if none */
20+
registry?: string;
21+
/** Tag (e.g. latest, 1.23.0); undefined if only digest or neither */
22+
tag?: string;
23+
/** Inline digest (e.g. sha256:abc...); undefined if not present */
24+
digest?: string;
25+
}
26+
27+
/**
28+
* Parse an OCI image reference into repository, registry, tag, and digest.
29+
*
30+
* @param reference - Image reference string (e.g. nginx:1.23.0@sha256:..., gcr.io/nginx:latest)
31+
* @returns ParsedImageReference
32+
* @throws "image name is empty" if reference is empty
33+
* @throws "image repository contains uppercase letter" if repo path has uppercase
34+
* @throws "invalid image reference format" if format is invalid
35+
*/
36+
export function parseImageReference(reference: string): ParsedImageReference {
37+
if (reference === "") {
38+
throw new Error("image name is empty");
39+
}
40+
41+
const groups = imageReferenceRegex.exec(reference);
42+
if (groups === null) {
43+
const lowerMatch = imageReferenceRegex.exec(reference.toLowerCase());
44+
if (lowerMatch !== null) {
45+
throw new Error("image repository contains uppercase letter");
46+
}
47+
throw new Error("invalid image reference format");
48+
}
49+
50+
let repository = groups[1];
51+
const tag = groups[2];
52+
const digest = groups[3];
53+
54+
let registry: string | undefined;
55+
const registryMatch = imageRegistryRegex.exec(repository);
56+
if (registryMatch !== null) {
57+
registry = registryMatch[1];
58+
repository = repository.slice(registry.length + 1);
59+
}
60+
61+
return {
62+
repository,
63+
registry,
64+
tag: tag ?? undefined,
65+
digest: digest ?? undefined,
66+
};
67+
}
68+
69+
/**
70+
* Validate a Docker/OCI image reference without throwing.
71+
*
72+
* @param reference - Image reference string to validate
73+
* @returns true if parseImageReference would succeed, false otherwise
74+
*/
75+
export function isValidImageReference(reference: string): boolean {
76+
try {
77+
parseImageReference(reference);
78+
return true;
79+
} catch {
80+
return false;
81+
}
82+
}

test/lib/image-reference.spec.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import {
2+
parseImageReference,
3+
isValidImageReference,
4+
} from "../../lib/image-reference";
5+
6+
const validSha256 =
7+
"sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234";
8+
9+
describe("image-reference", () => {
10+
describe("parseImageReference", () => {
11+
describe("valid image references", () => {
12+
it("parses image with tag only", () => {
13+
expect(parseImageReference("nginx:latest")).toEqual({
14+
repository: "nginx",
15+
registry: undefined,
16+
tag: "latest",
17+
digest: undefined,
18+
});
19+
});
20+
21+
it("parses image with semantic tag", () => {
22+
expect(parseImageReference("nginx:1.23.0")).toEqual({
23+
repository: "nginx",
24+
registry: undefined,
25+
tag: "1.23.0",
26+
digest: undefined,
27+
});
28+
});
29+
30+
it("parses image without tag or digest (repository only)", () => {
31+
expect(parseImageReference("nginx")).toEqual({
32+
repository: "nginx",
33+
registry: undefined,
34+
tag: undefined,
35+
digest: undefined,
36+
});
37+
});
38+
39+
it("parses image with digest only", () => {
40+
expect(parseImageReference(`nginx@${validSha256}`)).toEqual({
41+
repository: "nginx",
42+
registry: undefined,
43+
tag: undefined,
44+
digest: validSha256,
45+
});
46+
});
47+
48+
it("parses image with tag and digest (name:tag@digest)", () => {
49+
expect(
50+
parseImageReference(`nginx:1.23.0@${validSha256}`),
51+
).toEqual({
52+
repository: "nginx",
53+
registry: undefined,
54+
tag: "1.23.0",
55+
digest: validSha256,
56+
});
57+
});
58+
59+
it("parses image with registry (gcr.io)", () => {
60+
expect(parseImageReference("gcr.io/project/nginx:latest")).toEqual({
61+
repository: "project/nginx",
62+
registry: "gcr.io",
63+
tag: "latest",
64+
digest: undefined,
65+
});
66+
});
67+
68+
it("parses image with registry and digest", () => {
69+
expect(
70+
parseImageReference(`gcr.io/project/nginx:1.23.0@${validSha256}`),
71+
).toEqual({
72+
repository: "project/nginx",
73+
registry: "gcr.io",
74+
tag: "1.23.0",
75+
digest: validSha256,
76+
});
77+
});
78+
79+
it("parses localhost registry with port", () => {
80+
expect(
81+
parseImageReference("localhost:5000/foo/bar:tag"),
82+
).toEqual({
83+
repository: "foo/bar",
84+
registry: "localhost:5000",
85+
tag: "tag",
86+
digest: undefined,
87+
});
88+
});
89+
90+
it("parses docker.io style registry", () => {
91+
expect(
92+
parseImageReference("docker.io/calico/cni:release-v3.14"),
93+
).toEqual({
94+
repository: "calico/cni",
95+
registry: "docker.io",
96+
tag: "release-v3.14",
97+
digest: undefined,
98+
});
99+
});
100+
101+
it("parses library/ prefix (Docker Hub official images)", () => {
102+
expect(parseImageReference("library/nginx:latest")).toEqual({
103+
repository: "library/nginx",
104+
registry: undefined,
105+
tag: "latest",
106+
digest: undefined,
107+
});
108+
});
109+
110+
it("parses docker.io/library/ prefix (Docker Hub official images)", () => {
111+
expect(parseImageReference("docker.io/library/nginx:latest")).toEqual({
112+
repository: "library/nginx",
113+
registry: "docker.io",
114+
tag: "latest",
115+
digest: undefined,
116+
});
117+
});
118+
119+
it("parses repository with dots and dashes", () => {
120+
expect(parseImageReference("my.repo/image-name:tag")).toEqual({
121+
repository: "image-name",
122+
registry: "my.repo",
123+
tag: "tag",
124+
digest: undefined,
125+
});
126+
});
127+
128+
it("parses IPv6 registry", () => {
129+
expect(
130+
parseImageReference("[::1]:5000/foo/bar:latest"),
131+
).toEqual({
132+
repository: "foo/bar",
133+
registry: "[::1]:5000",
134+
tag: "latest",
135+
digest: undefined,
136+
});
137+
});
138+
139+
it("parses tag with dots and dashes", () => {
140+
expect(parseImageReference("nginx:1.23.0-alpha")).toEqual({
141+
repository: "nginx",
142+
registry: undefined,
143+
tag: "1.23.0-alpha",
144+
digest: undefined,
145+
});
146+
});
147+
});
148+
149+
describe("invalid image references", () => {
150+
it("throws for empty string", () => {
151+
expect(() => parseImageReference("")).toThrow("image name is empty");
152+
});
153+
154+
it("throws for invalid format (no repository)", () => {
155+
expect(() => parseImageReference(":tag")).toThrow(
156+
"invalid image reference format",
157+
);
158+
});
159+
160+
it("throws for invalid format (leading slash)", () => {
161+
expect(() => parseImageReference("/test:unknown")).toThrow(
162+
"invalid image reference format",
163+
);
164+
});
165+
166+
it("throws for uppercase in repository", () => {
167+
expect(() => parseImageReference("UPPERCASE")).toThrow(
168+
"image repository contains uppercase letter",
169+
);
170+
});
171+
172+
it("throws for uppercase in repository path with registry", () => {
173+
expect(() => parseImageReference("gcr.io/Project/nginx")).toThrow(
174+
"image repository contains uppercase letter",
175+
);
176+
});
177+
178+
it("throws for invalid digest (too short)", () => {
179+
expect(() =>
180+
parseImageReference("nginx@sha256:abc"),
181+
).toThrow("invalid image reference format");
182+
});
183+
184+
it("throws for malformed reference", () => {
185+
expect(() => parseImageReference("image:")).toThrow(
186+
"invalid image reference format",
187+
);
188+
});
189+
});
190+
});
191+
192+
describe("isValidImageReference", () => {
193+
it("returns true for valid references", () => {
194+
expect(isValidImageReference("nginx:latest")).toBe(true);
195+
expect(isValidImageReference("nginx")).toBe(true);
196+
expect(
197+
isValidImageReference(`nginx:1.23.0@${validSha256}`),
198+
).toBe(true);
199+
expect(isValidImageReference("gcr.io/project/nginx:latest")).toBe(true);
200+
});
201+
202+
it("returns false for empty string", () => {
203+
expect(isValidImageReference("")).toBe(false);
204+
});
205+
206+
it("returns false for invalid format", () => {
207+
expect(isValidImageReference(":tag")).toBe(false);
208+
expect(isValidImageReference("/invalid")).toBe(false);
209+
});
210+
211+
it("returns false for uppercase in repository", () => {
212+
expect(isValidImageReference("UPPERCASE")).toBe(false);
213+
});
214+
});
215+
});

0 commit comments

Comments
 (0)