Skip to main content

docker_registry_client/
docker.rs

1use reqwest::{
2    Client as HTTPClient,
3    header::HeaderMap,
4};
5use serde::{
6    Deserialize,
7    Serialize,
8};
9use tracing::{
10    Instrument,
11    info_span,
12};
13use url::Url;
14
15use crate::{
16    Image,
17    Manifest,
18    Registry,
19};
20
21mod error;
22pub mod token;
23pub mod token_cache;
24
25pub use error::Error;
26use token::Token;
27use token_cache::Cache as TokenCache;
28
29#[derive(Debug, Clone)]
30pub struct Client {
31    client: HTTPClient,
32    token_cache: Box<dyn TokenCache + Send>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Response {
37    pub digest: Option<String>,
38    pub manifest: Manifest,
39}
40
41impl Default for Client {
42    fn default() -> Self {
43        Self {
44            client: HTTPClient::new(),
45            token_cache: Box::new(token_cache::MemoryTokenCache::default()),
46        }
47    }
48}
49
50impl Client {
51    #[must_use]
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    pub fn set_cache_memory(&mut self) {
57        self.token_cache = Box::new(token_cache::MemoryTokenCache::default());
58    }
59
60    pub fn disable_caching(&mut self) {
61        self.token_cache = Box::new(token_cache::NoCache);
62    }
63
64    #[cfg(feature = "redis_cache")]
65    pub fn set_cache_redis(&mut self, redis_client: redis::Client) {
66        self.token_cache = Box::new(token_cache::RedisCache::new(redis_client));
67    }
68
69    /// Returns the manifest for the given URL and image.
70    ///
71    /// # Errors
72    /// Returns an error if the request fails.
73    /// Returns an error if the response body is not valid JSON.
74    /// Returns an error if the response body is not a valid manifest.
75    /// Returns an error if the response status is not successful.
76    #[tracing::instrument]
77    pub async fn get_manifest_url(&self, url: &Url, image: &Image) -> Result<Response, Error> {
78        let mut headers = self.get_headers(image).await?;
79
80        let accept_header = [
81            "application/vnd.docker.container.image.v1+json",
82            "application/vnd.docker.distribution.manifest.list.v2+json",
83            "application/vnd.docker.distribution.manifest.v2+json",
84            "application/vnd.docker.image.rootfs.diff.tar.gzip",
85            "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
86            "application/vnd.docker.plugin.v1+json",
87            "application/vnd.oci.image.index.v1+json",
88            "application/vnd.oci.image.manifest.v1+json",
89        ]
90        .join(", ");
91
92        headers.insert(
93            "Accept",
94            accept_header
95                .parse()
96                .map_err(Error::ParseManifestAcceptHeader)?,
97        );
98
99        let response = self
100            .client
101            .get(url.as_str())
102            .headers(headers)
103            .send()
104            .instrument(info_span!("get manifest request"))
105            .await
106            .map_err(Error::GetManifest)?;
107
108        let status = response.status();
109
110        let digest = response
111            .headers()
112            .get("Docker-Content-Digest")
113            .map(|header| {
114                header
115                    .to_str()
116                    .map(String::from)
117                    .map_err(Error::ParseDockerContentDigestHeader)
118            })
119            .transpose()?;
120
121        let body = response
122            .text()
123            .instrument(info_span!("extract manifest request body"))
124            .await
125            .map_err(Error::ExtractManifestBody)?;
126
127        if !status.is_success() {
128            if status == reqwest::StatusCode::NOT_FOUND {
129                return Err(Error::ManifestNotFound(url.clone()));
130            }
131
132            return Err(Error::FailedManifestRequest(status, body));
133        }
134
135        let manifest =
136            serde_json::from_str(&body).map_err(|e| Error::DeserializeManifestBody(e, body))?;
137
138        Ok(Response { digest, manifest })
139    }
140
141    /// # Errors
142    /// Returns an error if the request fails.
143    /// Returns an error if the response body is not valid JSON.
144    /// Returns an error if the response body is not a valid manifest.
145    /// Returns an error if the response status is not successful.
146    #[tracing::instrument(skip_all)]
147    pub async fn get_manifest(&self, image: &Image) -> Result<Response, Error> {
148        let registry_domain = image.registry.registry_domain();
149
150        let url = Url::parse(&format!(
151            "https://{domain}/v2/{namespace}{repository}{image_name}/manifests/{identifier}",
152            domain = registry_domain,
153            namespace = match image.namespace {
154                Some(ref namespace) => format!("{namespace}/"),
155                None => String::new(),
156            },
157            repository = match image.repository {
158                Some(ref repository) => format!("{repository}/"),
159                None => String::new(),
160            },
161            image_name = image.image_name.name,
162            identifier = image.image_name.identifier
163        ))
164        .map_err(Error::InvalidManifestUrl)?;
165
166        self.get_manifest_url(&url, image).await
167    }
168
169    #[tracing::instrument(skip_all)]
170    async fn get_headers(&self, image: &Image) -> Result<HeaderMap, Error> {
171        if !image.registry.needs_authentication() {
172            return Ok(HeaderMap::new());
173        }
174
175        let cache_key = image.into();
176
177        let token = self
178            .token_cache
179            .fetch(&cache_key)
180            .await
181            .map_err(Error::FetchToken)?;
182
183        let token = if let Some(token) = token {
184            token
185        } else {
186            let namespace = match &image.namespace {
187                Some(namespace) => format!("{namespace}/"),
188                None => String::new(),
189            };
190
191            let repository = match &image.repository {
192                Some(repository) => format!("{repository}/"),
193                None => String::new(),
194            };
195
196            let token_url = match image.registry {
197                Registry::Github => format!(
198                    "https://ghcr.io/token?scope=repository:{namespace}{repository}{image_name}:pull&service=ghcr.io",
199                    image_name = image.image_name.name
200                ),
201
202                Registry::DockerHub => format!("https://auth.docker.io/token?service=registry.docker.io&scope=repository:{namespace}{repository}{image_name}:pull&service=registry.docker.io", image_name = image.image_name.name),
203
204                Registry::Quay => format!("https://quay.io/v2/auth?scope=repository:{namespace}{repository}{image_name}:pull&service=quay.io", image_name = image.image_name.name),
205
206                Registry::RedHat | Registry::K8s | Registry::Google | Registry::Microsoft => return Ok(HeaderMap::new()),
207            };
208
209            let token_url = Url::parse(&token_url).map_err(Error::InvalidTokenUrl)?;
210
211            let response = self
212                .client
213                .get(token_url)
214                .send()
215                .instrument(info_span!("get token request"))
216                .await
217                .map_err(Error::GetToken)?;
218
219            let body = response
220                .text()
221                .instrument(info_span!("extract token request body"))
222                .await
223                .map_err(Error::ExtractTokenBody)?;
224
225            let token: Token =
226                serde_json::from_str(&body).map_err(|e| Error::DeserializeToken(e, body))?;
227
228            self.token_cache
229                .store(cache_key, token.clone())
230                .await
231                .map_err(Error::StoreToken)?;
232
233            token
234        };
235
236        let headers = token.try_into().map_err(Error::ParseAuthorizationHeader)?;
237
238        Ok(headers)
239    }
240}
241
242#[cfg(test)]
243#[expect(clippy::unwrap_used, reason = "using unwrap in tests is fine")]
244mod tests {
245    mod dockerhub {
246        use crate::{
247            Client,
248            Image,
249            ImageName,
250            Registry,
251            Tag,
252        };
253        use either::Either;
254
255        #[tokio::test]
256        async fn alpine() {
257            let client = Client::new();
258
259            let image_name = Image {
260                registry: Registry::DockerHub,
261                namespace: None,
262                repository: Some("library".to_string()),
263                image_name: ImageName {
264                    name: "alpine".to_string(),
265                    identifier: Either::Left(Tag::Specific("3.20".to_string())),
266                },
267            };
268
269            let response = client.get_manifest(&image_name).await.unwrap();
270
271            insta::assert_json_snapshot!(response);
272        }
273    }
274
275    mod redhat {
276        use crate::{
277            Client,
278            Image,
279            ImageName,
280            Registry,
281            Tag,
282        };
283        use either::Either;
284
285        #[tokio::test]
286        async fn ubi8() {
287            let client = Client::new();
288
289            let image = Image {
290                registry: Registry::RedHat,
291                namespace: None,
292                repository: None,
293                image_name: ImageName {
294                    name: "ubi8".to_string(),
295                    identifier: Either::Left(Tag::Specific("8.9".to_string())),
296                },
297            };
298
299            let response = client.get_manifest(&image).await.unwrap();
300
301            insta::assert_json_snapshot!(response);
302        }
303
304        #[tokio::test]
305        async fn cosign() {
306            const INPUT: &str = "ghcr.io/sigstore/cosign/cosign:v2.4.0";
307
308            let client = Client::new();
309            let image = INPUT.parse().unwrap();
310            let response = client.get_manifest(&image).await.unwrap();
311
312            insta::assert_json_snapshot!(response);
313        }
314
315        #[tokio::test]
316        async fn playwright() {
317            const INPUT: &str = "mcr.microsoft.com/playwright:v1.48.2-noble";
318
319            let client = Client::new();
320            let image = INPUT.parse().unwrap();
321            let response = client.get_manifest(&image).await.unwrap();
322
323            insta::assert_json_snapshot!(response);
324        }
325    }
326}