docker_registry/
reference.rs

1//! Parser for `docker://` URLs.
2//!
3//! This module provides support for parsing image references.
4//!
5//! ## Example
6//!
7//! ```rust
8//! # fn main() {
9//! # fn run() -> docker_registry::errors::Result<()> {
10//! #
11//! use std::str::FromStr;
12//!
13//! use docker_registry::reference::Reference;
14//!
15//! // Parse an image reference
16//! let dkref = Reference::from_str("docker://busybox")?;
17//! assert_eq!(dkref.registry(), "registry-1.docker.io");
18//! assert_eq!(dkref.repository(), "library/busybox");
19//! assert_eq!(dkref.version(), "latest");
20//! #
21//! # Ok(())
22//! # };
23//! # run().unwrap();
24//! # }
25//! ```
26
27// The `docker://` schema is not officially documented, but has a reference implementation:
28// https://github.com/docker/distribution/blob/v2.6.1/reference/reference.go
29
30use std::{collections::VecDeque, fmt, str, str::FromStr};
31
32use regex_lite::Regex;
33
34pub static DEFAULT_REGISTRY: &str = "registry-1.docker.io";
35static DEFAULT_TAG: &str = "latest";
36static DEFAULT_SCHEME: &str = "docker";
37
38/// Image version, either a tag or a digest.
39#[derive(Clone)]
40pub enum Version {
41  Tag(String),
42  Digest(String, String),
43}
44
45#[derive(thiserror::Error, Debug)]
46pub enum VersionParseError {
47  #[error("wrong digest format: checksum missing")]
48  WrongDigestFormat,
49  #[error("unknown prefix: digest must start from : or @")]
50  UnknownPrefix,
51  #[error("empty string is invalid digest")]
52  Empty,
53}
54
55impl str::FromStr for Version {
56  type Err = VersionParseError;
57  fn from_str(s: &str) -> Result<Self, Self::Err> {
58    let v = match s.chars().next() {
59      Some(':') => Version::Tag(s.trim_start_matches(':').to_string()),
60      Some('@') => {
61        let r: Vec<&str> = s.trim_start_matches('@').splitn(2, ':').collect();
62        if r.len() != 2 {
63          return Err(VersionParseError::WrongDigestFormat);
64        };
65        Version::Digest(r[0].to_string(), r[1].to_string())
66      }
67      Some(_) => return Err(VersionParseError::UnknownPrefix),
68      None => return Err(VersionParseError::Empty),
69    };
70    Ok(v)
71  }
72}
73
74impl Default for Version {
75  fn default() -> Self {
76    Version::Tag("latest".to_string())
77  }
78}
79
80impl fmt::Debug for Version {
81  fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
82    let v = match *self {
83      Version::Tag(ref s) => ":".to_string() + s,
84      Version::Digest(ref t, ref d) => "@".to_string() + t + ":" + d,
85    };
86    write!(f, "{v}")
87  }
88}
89
90impl fmt::Display for Version {
91  fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
92    let v = match *self {
93      Version::Tag(ref s) => s.to_string(),
94      Version::Digest(ref t, ref d) => t.to_string() + ":" + d,
95    };
96    write!(f, "{v}")
97  }
98}
99
100/// A registry image reference.
101#[derive(Clone, Debug, Default)]
102pub struct Reference {
103  raw_input: String,
104  registry: String,
105  repository: String,
106  version: Version,
107}
108
109impl Reference {
110  pub fn new(registry: Option<String>, repository: String, version: Option<Version>) -> Self {
111    let reg = registry.unwrap_or_else(|| DEFAULT_REGISTRY.to_string());
112    let ver = version.unwrap_or_else(|| Version::Tag(DEFAULT_TAG.to_string()));
113    Self {
114      raw_input: "".into(),
115      registry: reg,
116      repository,
117      version: ver,
118    }
119  }
120
121  pub fn registry(&self) -> String {
122    self.registry.clone()
123  }
124
125  pub fn repository(&self) -> String {
126    self.repository.clone()
127  }
128
129  pub fn version(&self) -> String {
130    self.version.to_string()
131  }
132
133  pub fn to_raw_string(&self) -> String {
134    self.raw_input.clone()
135  }
136
137  //TODO(lucab): move this to a real URL type
138  pub fn to_url(&self) -> String {
139    format!(
140      "{}://{}/{}{:?}",
141      DEFAULT_SCHEME, self.registry, self.repository, self.version
142    )
143  }
144}
145
146impl fmt::Display for Reference {
147  fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
148    write!(f, "{}/{}{:?}", self.registry, self.repository, self.version)
149  }
150}
151
152impl str::FromStr for Reference {
153  type Err = ReferenceParseError;
154  fn from_str(s: &str) -> Result<Self, Self::Err> {
155    parse_url(s)
156  }
157}
158
159#[derive(thiserror::Error, Debug)]
160pub enum ReferenceParseError {
161  #[error("missing image name")]
162  MissingImageName,
163  #[error("version parse error")]
164  VersionParse(#[from] VersionParseError),
165  #[error("empty image name")]
166  EmptyImageName,
167  #[error("component '{component}' does not conform to regex '{regex}'")]
168  RegexViolation { regex: &'static str, component: String },
169  #[error("empty repository name")]
170  EmptyRepositoryName,
171  #[error("repository name too long")]
172  RepositoryNameTooLong,
173}
174
175fn parse_url(input: &str) -> Result<Reference, ReferenceParseError> {
176  // TODO(lucab): investigate using a grammar-based parser.
177  let mut rest = input;
178
179  // Detect and remove schema.
180  let has_schema = rest.starts_with("docker://");
181  if has_schema {
182    rest = input.trim_start_matches("docker://");
183  };
184
185  // Split path components apart and retain non-empty ones.
186  let mut components: VecDeque<String> = rest.split('/').filter(|s| !s.is_empty()).map(String::from).collect();
187
188  // Figure out if the first component is a registry String, and assume the
189  // default registry if it's not.
190  let first = components.pop_front().ok_or(ReferenceParseError::MissingImageName)?;
191
192  let registry = if Regex::new(
193    r"(?x)
194        ^
195        # hostname
196        (([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])
197
198        # optional port
199        ([:][0-9]{1,6})?
200        $
201    ",
202  )
203  .expect("hardcoded regex is invalid")
204  .is_match(&first)
205  {
206    first
207  } else {
208    components.push_front(first);
209    DEFAULT_REGISTRY.to_string()
210  };
211
212  // Take image name and extract tag or digest-ref, if any.
213  let last = components.pop_back().ok_or(ReferenceParseError::MissingImageName)?;
214  let (image_name, version) = match (last.rfind('@'), last.rfind(':')) {
215    (Some(i), _) | (None, Some(i)) => {
216      let s = last.split_at(i);
217      (String::from(s.0), Version::from_str(s.1)?)
218    }
219    (None, None) => (last, Version::default()),
220  };
221  if image_name.is_empty() {
222    return Err(ReferenceParseError::EmptyImageName);
223  }
224
225  // Handle images in default library namespace, that is:
226  // `ubuntu` -> `library/ubuntu`
227  if components.is_empty() && registry == DEFAULT_REGISTRY {
228    components.push_back("library".to_string());
229  }
230  components.push_back(image_name);
231
232  // Check if all path components conform to the regex at
233  // https://docs.docker.com/registry/spec/api/#overview.
234  const REGEX: &str = "^[a-z0-9]+(?:[._-][a-z0-9]+)*$";
235  let path_re = Regex::new(REGEX).expect("hardcoded regex is invalid");
236  components.iter().try_for_each(|component| {
237    if !path_re.is_match(component) {
238      return Err(ReferenceParseError::RegexViolation {
239        component: component.clone(),
240        regex: REGEX,
241      });
242    };
243
244    Ok(())
245  })?;
246
247  // Re-assemble repository name.
248  let repository = components.into_iter().collect::<Vec<_>>().join("/");
249  if repository.is_empty() {
250    return Err(ReferenceParseError::EmptyRepositoryName);
251  }
252  if repository.len() > 127 {
253    return Err(ReferenceParseError::RepositoryNameTooLong);
254  }
255
256  Ok(Reference {
257    raw_input: input.to_string(),
258    registry,
259    repository,
260    version,
261  })
262}