1#[derive(Clone, Debug, PartialEq)]
3pub enum Image {
4 OfficialRelease {
6 major: Major,
7 minor: Minor,
8 os: OS,
9 digest: Option<Digest>,
10 },
11 OfficialReleaseCandidate {
13 major: Major,
14 number: ReleaseCandidateNumber,
15 os: OS,
16 digest: Option<Digest>,
17 },
18 OfficialLatest { os: OS, digest: Option<Digest> },
24 Explicit(ociman::image::Reference),
26}
27
28impl std::default::Default for Image {
29 fn default() -> Self {
30 Self::OfficialLatest {
31 os: OS::Default,
32 digest: None,
33 }
34 }
35}
36
37impl std::fmt::Display for Image {
38 fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
39 match self {
40 Self::OfficialRelease {
41 major,
42 minor,
43 os,
44 digest,
45 } => {
46 write!(formatter, "{major}{minor}{os}")?;
47 if let Some(digest) = digest {
48 write!(formatter, "@{digest}")?;
49 }
50 Ok(())
51 }
52 Self::OfficialReleaseCandidate {
53 major,
54 number,
55 os,
56 digest,
57 } => {
58 write!(formatter, "{major}rc{number}{os}")?;
59 if let Some(digest) = digest {
60 write!(formatter, "@{digest}")?;
61 }
62 Ok(())
63 }
64 Self::OfficialLatest { os, digest } => {
65 match os {
66 OS::Default => write!(formatter, "latest")?,
67 OS::Explicit(value) => write!(formatter, "{value}")?,
68 }
69 if let Some(digest) = digest {
70 write!(formatter, "@{digest}")?;
71 }
72 Ok(())
73 }
74 Self::Explicit(image) => write!(formatter, "{image}"),
75 }
76 }
77}
78
79impl std::str::FromStr for Image {
80 type Err = String;
81
82 fn from_str(value: &str) -> Result<Self, Self::Err> {
83 use nom::{
84 Finish, IResult, Parser,
85 branch::alt,
86 bytes::complete::{tag, take_while_m_n, take_while1},
87 character::complete::digit1,
88 combinator::{cut, opt, recognize},
89 error::context,
90 sequence::{pair, preceded},
91 };
92 use nom_language::error::VerboseError;
93
94 type ParseResult<'a, O> = IResult<&'a str, O, VerboseError<&'a str>>;
95
96 fn os_name(input: &str) -> ParseResult<'_, &str> {
97 context(
98 "OS name",
99 recognize(pair(
100 take_while_m_n(1, 1, |ch: char| ch.is_ascii_lowercase()),
101 take_while1(|ch: char| {
102 ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.'
103 }),
104 )),
105 )
106 .parse(input)
107 }
108
109 fn os_suffix(input: &str) -> ParseResult<'_, OS> {
110 context(
111 "OS suffix",
112 preceded(tag("-"), os_name).map(|name: &str| OS::Explicit(name.to_string())),
113 )
114 .parse(input)
115 }
116
117 fn digest(input: &str) -> ParseResult<'_, Digest> {
118 context(
119 "digest",
120 preceded(
121 tag("@sha256:"),
122 cut(take_while_m_n(64, 64, |ch: char| ch.is_ascii_hexdigit())),
123 )
124 .map_res(|hash: &str| {
125 hex::decode(hash)
126 .map_err(|err| format!("invalid hex: {err}"))
127 .and_then(|bytes| {
128 bytes
129 .try_into()
130 .map(Digest)
131 .map_err(|_| "hash must be exactly 32 bytes".to_string())
132 })
133 }),
134 )
135 .parse(input)
136 }
137
138 fn latest(input: &str) -> ParseResult<'_, Image> {
139 context(
140 "latest image",
141 (tag("latest"), opt(digest)).map(|(_, digest)| Image::OfficialLatest {
142 os: OS::Default,
143 digest,
144 }),
145 )
146 .parse(input)
147 }
148
149 fn os_only(input: &str) -> ParseResult<'_, Image> {
150 context(
151 "OS-only image",
152 (os_name, opt(digest)).map(|(os, digest)| Image::OfficialLatest {
153 os: OS::Explicit(os.to_string()),
154 digest,
155 }),
156 )
157 .parse(input)
158 }
159
160 fn release_candidate(input: &str) -> ParseResult<'_, Image> {
161 context(
162 "release candidate image",
163 (
164 digit1.map_res(|digits: &str| digits.parse::<u8>().map(Major)),
165 preceded(
166 tag("rc"),
167 digit1.map_res(|digits: &str| {
168 digits
169 .parse::<std::num::NonZero<u8>>()
170 .map(ReleaseCandidateNumber)
171 }),
172 ),
173 opt(os_suffix),
174 opt(digest),
175 )
176 .map(|(major, number, os, digest)| {
177 Image::OfficialReleaseCandidate {
178 major,
179 number,
180 os: os.unwrap_or(OS::Default),
181 digest,
182 }
183 }),
184 )
185 .parse(input)
186 }
187
188 fn official_release(input: &str) -> ParseResult<'_, Image> {
189 context(
190 "official release image",
191 (
192 digit1.map_res(|digits: &str| digits.parse::<u8>().map(Major)),
193 opt(preceded(
194 tag("."),
195 digit1.map_res(|digits: &str| digits.parse::<u8>().map(Minor::Explicit)),
196 )),
197 opt(os_suffix),
198 opt(digest),
199 )
200 .map(|(major, minor, os, digest)| Image::OfficialRelease {
201 major,
202 minor: minor.unwrap_or(Minor::Latest),
203 os: os.unwrap_or(OS::Default),
204 digest,
205 }),
206 )
207 .parse(input)
208 }
209
210 fn image(input: &str) -> ParseResult<'_, Image> {
211 alt((latest, release_candidate, official_release, os_only)).parse(input)
212 }
213
214 match image(value).finish() {
215 Ok(("", result)) => Ok(result),
216 Ok((remaining, _)) => Err(format!("unexpected trailing input: '{remaining}'")),
217 Err(error) => Err(nom_language::error::convert_error(value, error)),
218 }
219 }
220}
221
222impl<'de> serde::Deserialize<'de> for Image {
223 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
224 <String as serde::de::Deserialize<'de>>::deserialize(deserializer)
225 .and_then(|value| value.parse().map_err(serde::de::Error::custom))
226 }
227}
228
229impl From<&Image> for ociman::image::Reference {
235 fn from(image: &Image) -> Self {
236 match image {
237 Image::Explicit(reference) => reference.clone(),
238 Image::OfficialRelease { .. }
239 | Image::OfficialReleaseCandidate { .. }
240 | Image::OfficialLatest { .. } => {
241 format!("registry.hub.docker.com/library/postgres:{image}")
242 .parse()
243 .unwrap()
244 }
245 }
246 }
247}
248
249#[derive(Clone, Debug, PartialEq)]
250pub struct Major(u8);
251
252impl std::fmt::Display for Major {
253 fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
254 write!(formatter, "{}", self.0)
255 }
256}
257
258impl Major {
259 #[must_use]
260 pub const fn new(value: u8) -> Self {
261 Self(value)
262 }
263}
264
265#[derive(Clone, Debug, PartialEq)]
266pub enum Minor {
267 Explicit(u8),
268 Latest,
269}
270
271impl std::fmt::Display for Minor {
272 fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
273 match self {
274 Self::Explicit(number) => write!(formatter, ".{number}"),
275 Self::Latest => write!(formatter, ""),
276 }
277 }
278}
279
280#[derive(Clone, Debug, PartialEq)]
281pub struct ReleaseCandidateNumber(std::num::NonZero<u8>);
282
283impl ReleaseCandidateNumber {
284 #[must_use]
285 pub const fn new(value: std::num::NonZero<u8>) -> Self {
286 Self(value)
287 }
288}
289
290impl std::fmt::Display for ReleaseCandidateNumber {
291 fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
292 write!(formatter, "{}", self.0)
293 }
294}
295
296#[derive(Clone, Debug, PartialEq)]
298pub enum OS {
299 Default,
300 Explicit(String),
301}
302
303impl std::fmt::Display for OS {
304 fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
305 match self {
306 Self::Default => write!(formatter, ""),
307 Self::Explicit(value) => write!(formatter, "-{value}"),
308 }
309 }
310}
311
312#[derive(Clone, Debug, PartialEq)]
314pub struct Digest([u8; 32]);
315
316impl std::fmt::Display for Digest {
317 fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
318 write!(formatter, "sha256:{}", hex::encode(self.0))
319 }
320}
321
322#[cfg(test)]
323mod test {
324 use super::*;
325
326 #[test]
327 fn test_image_string() {
328 assert_image(
329 "latest",
330 &Image::OfficialLatest {
331 os: OS::Default,
332 digest: None,
333 },
334 );
335
336 assert_image(
337 "trixie",
338 &Image::OfficialLatest {
339 os: OS::Explicit("trixie".to_string()),
340 digest: None,
341 },
342 );
343
344 assert_image(
345 "18rc1",
346 &Image::OfficialReleaseCandidate {
347 major: Major(18),
348 number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
349 os: OS::Default,
350 digest: None,
351 },
352 );
353
354 assert_image(
355 "18rc1-trixie",
356 &Image::OfficialReleaseCandidate {
357 major: Major(18),
358 number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
359 os: OS::Explicit("trixie".to_string()),
360 digest: None,
361 },
362 );
363
364 assert_image(
365 "18rc1-bookworm",
366 &Image::OfficialReleaseCandidate {
367 major: Major(18),
368 number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
369 os: OS::Explicit("bookworm".to_string()),
370 digest: None,
371 },
372 );
373
374 assert_image(
375 "18rc1-alpine3.22",
376 &Image::OfficialReleaseCandidate {
377 major: Major(18),
378 number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
379 os: OS::Explicit("alpine3.22".to_string()),
380 digest: None,
381 },
382 );
383
384 assert_image(
385 "18rc1-alpine3.21",
386 &Image::OfficialReleaseCandidate {
387 major: Major(18),
388 number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
389 os: OS::Explicit("alpine3.21".to_string()),
390 digest: None,
391 },
392 );
393
394 assert_image(
395 "18rc1-alpine",
396 &Image::OfficialReleaseCandidate {
397 major: Major(18),
398 number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
399 os: OS::Explicit("alpine".to_string()),
400 digest: None,
401 },
402 );
403
404 assert_image(
405 "17",
406 &Image::OfficialRelease {
407 major: Major(17),
408 minor: Minor::Latest,
409 os: OS::Default,
410 digest: None,
411 },
412 );
413
414 assert_image(
415 "17-trixie",
416 &Image::OfficialRelease {
417 major: Major(17),
418 minor: Minor::Latest,
419 os: OS::Explicit("trixie".to_string()),
420 digest: None,
421 },
422 );
423
424 assert_image(
425 "17.6",
426 &Image::OfficialRelease {
427 major: Major(17),
428 minor: Minor::Explicit(6),
429 os: OS::Default,
430 digest: None,
431 },
432 );
433
434 assert_image(
435 "17.6-trixie",
436 &Image::OfficialRelease {
437 major: Major(17),
438 minor: Minor::Explicit(6),
439 os: OS::Explicit("trixie".to_string()),
440 digest: None,
441 },
442 );
443 }
444
445 fn assert_image(syntax: &str, expected: &Image) {
446 assert_eq!(syntax.parse().as_ref(), Ok(expected), "parses: {syntax:#?}");
447 assert_eq!(format!("{expected}"), syntax, "generates: {syntax:#?}");
448 }
449
450 #[test]
451 fn test_image_with_digest() {
452 let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
453 let parsed_digest = Some(Digest(hex::decode(hash).unwrap().try_into().unwrap()));
454
455 assert_image(
457 "17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
458 &Image::OfficialRelease {
459 major: Major(17),
460 minor: Minor::Explicit(6),
461 os: OS::Default,
462 digest: parsed_digest.clone(),
463 },
464 );
465
466 assert_image(
467 "17.6-trixie@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
468 &Image::OfficialRelease {
469 major: Major(17),
470 minor: Minor::Explicit(6),
471 os: OS::Explicit("trixie".to_string()),
472 digest: parsed_digest.clone(),
473 },
474 );
475
476 assert_image(
477 "17@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
478 &Image::OfficialRelease {
479 major: Major(17),
480 minor: Minor::Latest,
481 os: OS::Default,
482 digest: parsed_digest.clone(),
483 },
484 );
485
486 assert_image(
488 "18rc1@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
489 &Image::OfficialReleaseCandidate {
490 major: Major(18),
491 number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
492 os: OS::Default,
493 digest: parsed_digest.clone(),
494 },
495 );
496
497 assert_image(
498 "18rc1-alpine@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
499 &Image::OfficialReleaseCandidate {
500 major: Major(18),
501 number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
502 os: OS::Explicit("alpine".to_string()),
503 digest: parsed_digest.clone(),
504 },
505 );
506
507 assert_image(
509 "latest@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
510 &Image::OfficialLatest {
511 os: OS::Default,
512 digest: parsed_digest.clone(),
513 },
514 );
515
516 assert_image(
517 "trixie@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
518 &Image::OfficialLatest {
519 os: OS::Explicit("trixie".to_string()),
520 digest: parsed_digest.clone(),
521 },
522 );
523 }
524
525 #[test]
526 fn test_ociman_image_conversion_with_digest() {
527 let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
528 let image = Image::OfficialRelease {
529 major: Major(17),
530 minor: Minor::Explicit(6),
531 os: OS::Default,
532 digest: Some(Digest(hex::decode(hash).unwrap().try_into().unwrap())),
533 };
534
535 let reference: ociman::image::Reference = (&image).into();
536 let expected = "registry.hub.docker.com/library/postgres:17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
537
538 assert_eq!(reference.to_string(), expected);
539 }
540
541 #[test]
542 fn test_parse_error_uppercase() {
543 let error = "LATEST".parse::<Image>().unwrap_err();
544 let expected = indoc::indoc! {"
545 0: at line 1, in TakeWhileMN:
546 LATEST
547 ^
548
549 1: at line 1, in OS name:
550 LATEST
551 ^
552
553 2: at line 1, in OS-only image:
554 LATEST
555 ^
556
557 3: at line 1, in Alt:
558 LATEST
559 ^
560
561 "};
562 assert_eq!(error, expected);
563 }
564
565 #[test]
566 fn test_parse_error_invalid_rc() {
567 let error = "17rc".parse::<Image>().unwrap_err();
568 let expected = "unexpected trailing input: 'rc'";
569 assert_eq!(error, expected);
570 }
571
572 #[test]
573 fn test_parse_error_short_digest() {
574 let error = "17@sha256:abc".parse::<Image>().unwrap_err();
575 let expected = indoc::indoc! {"
576 0: at line 1, in TakeWhileMN:
577 17@sha256:abc
578 ^
579
580 1: at line 1, in digest:
581 17@sha256:abc
582 ^
583
584 2: at line 1, in official release image:
585 17@sha256:abc
586 ^
587
588 "};
589 assert_eq!(error, expected);
590 }
591
592 #[test]
593 fn test_parse_error_trailing_dash() {
594 let error = "17-".parse::<Image>().unwrap_err();
595 let expected = "unexpected trailing input: '-'";
596 assert_eq!(error, expected);
597 }
598
599 #[test]
600 fn test_parse_error_trailing_content() {
601 let error = "17.6.5".parse::<Image>().unwrap_err();
602 let expected = "unexpected trailing input: '.5'";
603 assert_eq!(error, expected);
604 }
605
606 #[test]
607 fn test_parse_error_invalid_os_name() {
608 let error = "17-9invalid".parse::<Image>().unwrap_err();
609 let expected = "unexpected trailing input: '-9invalid'";
610 assert_eq!(error, expected);
611 }
612}