├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── oci-image-download.rs └── oci-image-manifest.rs └── src ├── blob.rs ├── errors.rs ├── lib.rs ├── main.rs └── manifest.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "oci-registry-client" 3 | version = "0.2.1" 4 | authors = ["Erle Carrara ", "Anton Whalley "] 5 | edition = "2021" 6 | license = "MIT" 7 | description = "A async client for Docker Registry HTTP V2 protocol." 8 | homepage = "https://github.com/ecarrara/oci-registry-client" 9 | repository = "https://github.com/ecarrara/oci-registry-client" 10 | readme = "README.md" 11 | 12 | [dependencies] 13 | bytes = { version = "^1.4.0" } 14 | reqwest = { version = "0.11.14", features = ["json"] } 15 | tokio = { version = "^1", features = ["macros", "rt-multi-thread"] } 16 | serde = { version = "^1.0", features = ["derive"] } 17 | serde_json = { version = "^1.0" } 18 | sha2 = { version = "^0.8", optional = true } 19 | 20 | [features] 21 | default = ["sha256"] 22 | sha256 = ["sha2"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Erle Carrara 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | OCI Registry Client 2 | =================== 3 | 4 | [![crates.io](https://img.shields.io/crates/v/oci-registry-client.svg)](https://crates.io/crates/oci-registry-client) 5 | [![Documentation](https://docs.rs/oci-registry-client/badge.svg)](https://docs.rs/oci-registry-client) 6 | [![MIT](https://img.shields.io/github/license/ecarrara/oci-registry-client)](./LICENSE) 7 | 8 | A async client for [OCI compliant image registries](http://github.com/opencontainers/distribution-spec/blob/master/spec.md/) 9 | and [Docker Registry HTTP V2 protocol](https://docs.docker.com/registry/spec/api/). 10 | 11 | # Usage 12 | 13 | The [`DockerRegistryClientV2`] provides functions to query Registry API and download blobs. 14 | 15 | ```rust 16 | use std::{path::Path, fs::File, io::Write}; 17 | use oci_registry_client::DockerRegistryClientV2; 18 | 19 | #[tokio::main] 20 | async fn main() -> Result<(), Box> { 21 | let mut client = DockerRegistryClientV2::new( 22 | "registry.docker.io", 23 | "https://registry-1.docker.io", 24 | "https://auth.docker.io/token" 25 | ); 26 | let token = client.auth("repository", "library/ubuntu", "latest").await?; 27 | client.set_auth_token(Some(token)); 28 | 29 | let manifest = client.manifest("library/ubuntu", "latest").await?; 30 | println!("{:?}", manifest); 31 | 32 | for layer in &manifest.layers { 33 | let mut out_file = File::create(Path::new("/tmp/").join(&layer.digest))?; 34 | let mut blob = client.blob("library/ubuntu", &layer.digest).await?; 35 | 36 | while let Some(chunk) = blob.chunk().await? { 37 | out_file.write_all(&chunk)?; 38 | } 39 | } 40 | 41 | Ok(()) 42 | } 43 | ``` 44 | 45 | 46 | ## License 47 | 48 | This project is licensed under the [MIT 49 | License](https://github.com/ecarrara/oci-registry-client/blob/master/LICENSE). 50 | 51 | ## Contribution 52 | 53 | Unless you explicitly state otherwise, any contribution intentionally 54 | submitted for inclusion in oci-registry-client by you, shall be 55 | licensed as MIT, without any additional terms or conditions. 56 | -------------------------------------------------------------------------------- /examples/oci-image-download.rs: -------------------------------------------------------------------------------- 1 | use oci_registry_client::DockerRegistryClientV2; 2 | use std::{env, error::Error, fs::File, io::Write, path::Path}; 3 | 4 | #[tokio::main] 5 | async fn main() -> Result<(), Box> { 6 | let mut args = env::args(); 7 | let image = args.nth(1).unwrap_or("library/alpine".to_string()); 8 | let reference = args.nth(2).unwrap_or("latest".to_string()); 9 | let out_dir = args.nth(3).unwrap_or("/tmp".to_string()); 10 | 11 | let mut client = DockerRegistryClientV2::new( 12 | "registry.docker.io", 13 | "https://registry-1.docker.io", 14 | "https://auth.docker.io/token", 15 | ); 16 | 17 | match client.auth("repository", &image, "pull").await { 18 | Ok(token) => client.set_auth_token(Some(token)), 19 | Err(err) => { 20 | eprintln!("auth failed; err={}", err); 21 | std::process::exit(-1); 22 | } 23 | } 24 | 25 | match client.manifest(&image, &reference).await { 26 | Ok(manifest) => { 27 | for layer in &manifest.layers { 28 | println!("Downloading {} ...", layer.digest); 29 | match File::create(Path::new(&out_dir).join(&layer.digest.to_string())) { 30 | Ok(mut out_file) => match client.blob(&image, &layer.digest).await { 31 | Ok(mut blob) => { 32 | loop { 33 | match blob.chunk().await { 34 | Ok(Some(chunk)) => { 35 | if let Err(err) = out_file.write_all(&chunk) { 36 | eprintln!("failed to write layer; err={}", err); 37 | std::process::exit(-1); 38 | } 39 | } 40 | Ok(None) => break, 41 | Err(err) => { 42 | eprintln!("failed to download layer; err={}", err); 43 | std::process::exit(-1); 44 | } 45 | } 46 | } 47 | 48 | let downloaded_digest = blob.digest(); 49 | if downloaded_digest != layer.digest { 50 | eprintln!( 51 | "invalid sha256 hash: expected \"{}\", got \"{}\"", 52 | layer.digest, downloaded_digest 53 | ); 54 | std::process::exit(-1); 55 | } 56 | } 57 | Err(err) => { 58 | eprintln!("failed to fetch layer blob; err={}", err); 59 | std::process::exit(-1); 60 | } 61 | }, 62 | Err(err) => { 63 | eprintln!("failed to create layer file; err={}", err); 64 | std::process::exit(-1); 65 | } 66 | } 67 | } 68 | } 69 | Err(err) => { 70 | eprintln!("failed to get manifest; err={}", err); 71 | std::process::exit(-1); 72 | } 73 | } 74 | 75 | Ok(()) 76 | } 77 | -------------------------------------------------------------------------------- /examples/oci-image-manifest.rs: -------------------------------------------------------------------------------- 1 | use oci_registry_client::DockerRegistryClientV2; 2 | use serde_json; 3 | use std::env; 4 | use std::error::Error; 5 | 6 | #[tokio::main] 7 | async fn main() -> Result<(), Box> { 8 | let mut args = env::args(); 9 | let image = args.nth(1).unwrap_or("library/alpine".to_string()); 10 | let reference = args.next().unwrap_or("latest".to_string()); 11 | 12 | let mut client = DockerRegistryClientV2::new( 13 | "registry.docker.io", 14 | "https://registry-1.docker.io", 15 | "https://auth.docker.io/token", 16 | ); 17 | 18 | match client.auth("repository", &image, "pull").await { 19 | Ok(token) => client.set_auth_token(Some(token)), 20 | Err(err) => { 21 | eprintln!("auth failed; err={}", err); 22 | std::process::exit(-1); 23 | } 24 | } 25 | 26 | match client.manifest(&image, &reference).await { 27 | Ok(manifest) => { 28 | match serde_json::to_string(&manifest) { 29 | Ok(repr) => println!("{}", repr), 30 | Err(err) => { 31 | eprintln!("failed to parse manifest json; err={}", err); 32 | std::process::exit(-1); 33 | } 34 | } 35 | 36 | match client.config(&image, &manifest.config.digest).await { 37 | Ok(config) => match serde_json::to_string(&config) { 38 | Ok(repr) => println!("{}", repr), 39 | Err(err) => { 40 | eprintln!("failed to parse image config json; err={}", err); 41 | std::process::exit(-1); 42 | } 43 | }, 44 | Err(err) => { 45 | eprintln!("failed to get image config; err={}", err); 46 | std::process::exit(-1); 47 | } 48 | } 49 | } 50 | Err(err) => { 51 | eprintln!("failed to get manifest; err={}", err); 52 | std::process::exit(-1); 53 | } 54 | } 55 | 56 | Ok(()) 57 | } 58 | -------------------------------------------------------------------------------- /src/blob.rs: -------------------------------------------------------------------------------- 1 | //! A "blob" representation. 2 | //! 3 | //! This module provides a utility struct called [`Blob`]. 4 | //! 5 | //! You can iterate over a blob chunks to download it contents: 6 | //! 7 | //! ```ignore 8 | //! let response = reqwest::get("..."); 9 | //! let mut blob = Blob::from(response) 10 | //! 11 | //! while let Some(chunk) = blob.chunk().await? { 12 | //! out_file.write_all(&chunk)?; 13 | //! } 14 | //! ``` 15 | 16 | use crate::errors::ErrorResponse; 17 | use crate::manifest::Digest; 18 | use bytes::Bytes; 19 | use reqwest; 20 | #[cfg(feature = "sha256")] 21 | use sha2::{Digest as Sha256Digest, Sha256}; 22 | 23 | /// Blob represents a downloaded content in a Image Registry. 24 | pub struct Blob { 25 | response: reqwest::Response, 26 | len: Option, 27 | content_type: Option, 28 | #[cfg(feature = "sha256")] 29 | hasher: Sha256, 30 | } 31 | 32 | impl Blob { 33 | /// Returns the total length of this blob. 34 | #[allow(clippy::len_without_is_empty)] 35 | pub fn len(&self) -> Option { 36 | self.len 37 | } 38 | 39 | /// Returns the content type of this blob (example: 40 | /// Some("application/vnd.docker.image.rootfs.foreign.diff.tar.gzip")) 41 | pub fn content_type(&self) -> &Option { 42 | &self.content_type 43 | } 44 | 45 | /// Stream a chunk of the blob contents. 46 | pub async fn chunk(&mut self) -> Result, ErrorResponse> { 47 | match self.response.chunk().await { 48 | Ok(Some(chunk)) => { 49 | #[cfg(feature = "sha256")] 50 | self.hasher.input(&chunk); 51 | Ok(Some(chunk)) 52 | } 53 | Ok(None) => Ok(None), 54 | Err(err) => Err(ErrorResponse::RequestError(err)), 55 | } 56 | } 57 | 58 | /// Returns the sha256 hash of the downloaded content. 59 | #[cfg(feature = "sha256")] 60 | pub fn digest(self) -> Digest { 61 | Digest::from_sha256(self.hasher.result()) 62 | } 63 | } 64 | 65 | impl From for Blob { 66 | fn from(response: reqwest::Response) -> Self { 67 | let headers = response.headers(); 68 | let content_type = headers 69 | .get(reqwest::header::CONTENT_TYPE) 70 | .map(|v| std::str::from_utf8(v.as_ref()).unwrap().to_string()); 71 | let len = response.content_length().map(|v| v as usize); 72 | 73 | Self { 74 | len, 75 | content_type, 76 | response, 77 | hasher: Sha256::new(), 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | //! Error representation. 2 | 3 | use reqwest; 4 | use std::fmt; 5 | 6 | /// A list of errors. 7 | #[derive(serde::Deserialize, Debug)] 8 | pub struct ErrorList { 9 | errors: Vec, 10 | } 11 | 12 | /// An error. 13 | /// 14 | /// Represents an error returned by Image Registry API. 15 | #[allow(dead_code)] 16 | #[derive(serde::Deserialize, Debug)] 17 | pub struct Error { 18 | code: String, 19 | message: String, 20 | #[serde(default)] 21 | detail: serde_json::Value, 22 | } 23 | 24 | /// Details about an error. 25 | #[allow(dead_code)] 26 | #[derive(serde::Deserialize, Debug)] 27 | #[serde(rename_all = "PascalCase")] 28 | pub struct ErrorDetail { 29 | r#type: String, 30 | class: String, 31 | name: String, 32 | action: String, 33 | } 34 | 35 | /// Error response 36 | /// 37 | /// `APIError` is returned when Image Registry API returns an error, otherwise 38 | /// `RequestError` is returned 39 | #[derive(Debug)] 40 | pub enum ErrorResponse { 41 | APIError(ErrorList), 42 | RequestError(reqwest::Error), 43 | } 44 | 45 | impl std::fmt::Display for ErrorResponse { 46 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 47 | match self { 48 | Self::APIError(err) => { 49 | write!(f, "API error:")?; 50 | for e in err.errors.iter() { 51 | write!(f, "\n {}: {}", e.code, e.message)?; 52 | } 53 | Ok(()) 54 | } 55 | Self::RequestError(err) => write!(f, "Request error: {}", err), 56 | } 57 | } 58 | } 59 | 60 | impl std::error::Error for ErrorResponse {} 61 | 62 | impl From for ErrorResponse { 63 | fn from(error: reqwest::Error) -> Self { 64 | ErrorResponse::RequestError(error) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A async client for [OCI compliant image 2 | //! registries](http://github.com/opencontainers/distribution-spec/blob/master/spec.md/) 3 | //! and [Docker Registry HTTP V2 protocol](https://docs.docker.com/registry/spec/api/). 4 | //! 5 | //! # Usage 6 | //! 7 | //! The [`DockerRegistryClientV2`] provides functions to query Registry API and download blobs. 8 | //! 9 | //! ```no_run 10 | //! use std::{path::Path, fs::File, io::Write}; 11 | //! use oci_registry_client::DockerRegistryClientV2; 12 | //! 13 | //! # async fn example() -> Result<(), Box> { 14 | //! let mut client = DockerRegistryClientV2::new( 15 | //! "registry.docker.io", 16 | //! "https://registry-1.docker.io", 17 | //! "https://auth.docker.io/token" 18 | //! ); 19 | //! let token = client.auth("repository", "library/ubuntu", "latest").await?; 20 | //! client.set_auth_token(Some(token)); 21 | //! 22 | //! let manifest = client.manifest("library/ubuntu", "latest").await?; 23 | //! println!("{:?}", manifest); 24 | //! 25 | //! for layer in &manifest.layers { 26 | //! let mut out_file = File::create(Path::new("/tmp/").join(&layer.digest.to_string()))?; 27 | //! let mut blob = client.blob("library/ubuntu", &layer.digest).await?; 28 | //! 29 | //! while let Some(chunk) = blob.chunk().await? { 30 | //! out_file.write_all(&chunk)?; 31 | //! } 32 | //! } 33 | //! 34 | //! # Ok(()) 35 | //! # } 36 | //! ``` 37 | 38 | pub mod blob; 39 | pub mod errors; 40 | pub mod manifest; 41 | 42 | use blob::Blob; 43 | use errors::{ErrorList, ErrorResponse}; 44 | use manifest::{Digest, Image, Manifest, ManifestList}; 45 | use reqwest::{Method, StatusCode}; 46 | 47 | static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); 48 | 49 | /// Client to fetch image manifests and download blobs. 50 | /// 51 | /// DockerRegistryClientV2 provides functions to fetch manifests and download 52 | /// blobs from a OCI Image Registry (or a Docker Registry API V2). 53 | #[derive(Clone, Debug)] 54 | pub struct DockerRegistryClientV2 { 55 | service: String, 56 | api_url: String, 57 | oauth_url: String, 58 | auth_token: Option, 59 | client: reqwest::Client, 60 | } 61 | 62 | #[derive(serde::Deserialize, Debug)] 63 | #[serde(rename_all = "camelCase")] 64 | pub struct Version {} 65 | 66 | const MEDIA_TYPE_JSON: &str = "application/json"; 67 | const MEDIA_TYPE_MANIFEST_LIST_V2: &str = 68 | "application/vnd.docker.distribution.manifest.list.v2+json"; 69 | const MEDIA_TYPE_MANIFEST_V2: &str = "application/vnd.docker.distribution.manifest.v2+json"; 70 | const MEDIA_TYPE_IMAGE_CONFIG: &str = "application/vnd.docker.container.image.v1+json"; 71 | 72 | impl DockerRegistryClientV2 { 73 | /// Returns a new `DockerRegistryClientV2`. 74 | /// 75 | /// # Arguments 76 | /// 77 | /// * `service` - Name of a Image Registry Service (example: registry.docker.io) 78 | /// * `api_url` - Service HTTPS address (example: https://registry-1.docker.io) 79 | /// * `auth_url` - Address to get a OAuth 2.0 token for this service. 80 | /// 81 | /// # Example 82 | /// 83 | /// ```no_run 84 | /// # use oci_registry_client::DockerRegistryClientV2; 85 | /// let mut client = DockerRegistryClientV2::new( 86 | /// "registry.docker.io", 87 | /// "https://registry-1.docker.io", 88 | /// "https://auth.docker.io/token" 89 | /// ); 90 | /// ``` 91 | pub fn new>(service: T, api_url: T, oauth_url: T) -> Self { 92 | let client = reqwest::Client::builder() 93 | .user_agent(USER_AGENT) 94 | .build() 95 | .unwrap(); 96 | 97 | Self { 98 | service: service.into(), 99 | api_url: api_url.into(), 100 | oauth_url: oauth_url.into(), 101 | auth_token: None, 102 | client, 103 | } 104 | } 105 | 106 | /// Set access token to authenticate subsequent requests. 107 | pub fn set_auth_token(&mut self, token: Option) { 108 | self.auth_token = token; 109 | } 110 | 111 | /// Fetch a access token from `auth_url` for this `service`. 112 | /// 113 | /// # Arguments 114 | /// 115 | /// * `type` - Scope type (example: "repository"). 116 | /// * `name` - Name of resource (example: "library/ubuntu"). 117 | /// * `action` - List of actions separated by comma (example: "pull"). 118 | pub async fn auth( 119 | &self, 120 | r#type: &str, 121 | name: &str, 122 | action: &str, 123 | ) -> Result { 124 | let response = self 125 | .client 126 | .get(&self.oauth_url) 127 | .query(&[ 128 | ("service", self.service.clone()), 129 | ("scope", format!("{}:{}:{}", r#type, name, action)), 130 | ]) 131 | .send() 132 | .await?; 133 | 134 | match response.status() { 135 | StatusCode::OK => Ok(response.json::().await?), 136 | _ => Err(ErrorResponse::APIError(response.json::().await?)), 137 | } 138 | } 139 | 140 | /// Get API version. 141 | pub async fn version(&self) -> Result { 142 | let url = format!("{}/v2", self.api_url); 143 | self.request(Method::GET, &url, MEDIA_TYPE_JSON).await 144 | } 145 | 146 | /// List manifests from given image and reference. 147 | pub async fn list_manifests( 148 | &self, 149 | image: &str, 150 | reference: &str, 151 | ) -> Result { 152 | let url = format!("{}/v2/{}/manifests/{}", &self.api_url, image, reference); 153 | self.request(Method::GET, &url, MEDIA_TYPE_MANIFEST_LIST_V2) 154 | .await 155 | } 156 | 157 | /// Get the image manifest. 158 | pub async fn manifest(&self, image: &str, reference: &str) -> Result { 159 | let url = format!("{}/v2/{}/manifests/{}", &self.api_url, image, reference); 160 | self.request(Method::GET, &url, MEDIA_TYPE_MANIFEST_V2) 161 | .await 162 | } 163 | 164 | /// Get the container config. 165 | pub async fn config(&self, image: &str, reference: &Digest) -> Result { 166 | let url = format!("{}/v2/{}/blobs/{}", &self.api_url, image, reference); 167 | self.request(Method::GET, &url, MEDIA_TYPE_IMAGE_CONFIG) 168 | .await 169 | } 170 | 171 | /// Retrieve the blob from the registry identified by `digest`. 172 | pub async fn blob(&self, image: &str, digest: &Digest) -> Result { 173 | let url = format!("{}/v2/{}/blobs/{}", &self.api_url, image, digest); 174 | let mut request = self.client.get(&url); 175 | if let Some(token) = self.auth_token.clone() { 176 | request = request.bearer_auth(token.access_token); 177 | } 178 | 179 | let response = request.send().await?; 180 | 181 | match response.status() { 182 | StatusCode::OK => Ok(Blob::from(response)), 183 | _ => Err(ErrorResponse::APIError(response.json::().await?)), 184 | } 185 | } 186 | 187 | async fn request( 188 | &self, 189 | method: Method, 190 | url: &str, 191 | accept: &str, 192 | ) -> Result { 193 | let mut request = self 194 | .client 195 | .request(method, url) 196 | .header(reqwest::header::ACCEPT, accept); 197 | 198 | if let Some(token) = self.auth_token.clone() { 199 | request = request.bearer_auth(token.access_token); 200 | } 201 | 202 | let response = request.send().await?; 203 | 204 | match response.status() { 205 | StatusCode::OK => Ok(response.json::().await?), 206 | _ => Err(ErrorResponse::APIError(response.json::().await?)), 207 | } 208 | } 209 | } 210 | 211 | /// OAuth 2.0 token. 212 | #[allow(dead_code)] 213 | #[derive(serde::Deserialize, Clone, Debug)] 214 | pub struct AuthToken { 215 | access_token: String, 216 | expires_in: i32, 217 | issued_at: String, 218 | } 219 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use oci_registry_client::{ 2 | manifest::{Digest, Layer}, 3 | DockerRegistryClientV2, 4 | }; 5 | use std::error::Error; 6 | use std::fs::File; 7 | use std::io::Write; 8 | use tokio::sync::mpsc; 9 | 10 | #[derive(Debug)] 11 | struct DownloadProgressReport { 12 | n: usize, 13 | digest: Digest, 14 | downloaded: usize, 15 | total: usize, 16 | } 17 | 18 | async fn download_layer( 19 | n: usize, 20 | digest: Digest, 21 | layer: Layer, 22 | client: DockerRegistryClientV2, 23 | tx: mpsc::UnboundedSender, 24 | ) -> Result<(), Box> { 25 | let mut blob = client.blob("library/alpine", &layer.digest).await.unwrap(); 26 | let total = blob.len(); 27 | let mut downloaded = 0usize; 28 | let mut out_file = File::create(format!("/tmp/{}.tar.gz", layer.digest)).unwrap(); 29 | 30 | while let Some(chunk) = blob.chunk().await.unwrap() { 31 | downloaded += chunk.len(); 32 | if let Some(total) = total { 33 | tx.send(DownloadProgressReport { 34 | n, 35 | digest: digest.clone(), 36 | downloaded, 37 | total, 38 | }) 39 | .unwrap(); 40 | } 41 | 42 | out_file.write_all(&chunk).unwrap(); 43 | } 44 | 45 | Ok(()) 46 | } 47 | 48 | enum LayerDownloadStatus { 49 | Unknown(Digest), 50 | Downloading(Digest, usize, usize), 51 | Completed(Digest), 52 | } 53 | 54 | impl LayerDownloadStatus { 55 | pub fn completed(&self) -> bool { 56 | matches!(self, LayerDownloadStatus::Completed(_)) 57 | } 58 | } 59 | 60 | #[tokio::main] 61 | async fn main() -> Result<(), Box> { 62 | let mut client = DockerRegistryClientV2::new( 63 | "registry.docker.io", 64 | "https://registry-1.docker.io", 65 | "https://auth.docker.io/token", 66 | ); 67 | let response = client.auth("repository", "library/alpine", "pull").await; 68 | if let Ok(token) = response { 69 | client.set_auth_token(Some(token)); 70 | } 71 | 72 | let manifest_list = client.list_manifests("library/alpine", "latest").await?; 73 | 74 | for manifest in &manifest_list.manifests { 75 | println!("{:?}", manifest); 76 | if manifest.platform.architecture == "amd64" && manifest.platform.os == "linux" { 77 | let response = client 78 | .manifest("library/alpine", &manifest.digest.to_string()) 79 | .await?; 80 | 81 | println!("response: {:?}", response); 82 | } 83 | } 84 | 85 | let response = client.manifest("library/alpine", "latest").await?; 86 | 87 | let (tx, mut rx) = mpsc::unbounded_channel::(); 88 | 89 | let mut layers_status = vec![]; 90 | 91 | for (n, layer) in response.layers.iter().cloned().enumerate() { 92 | let client = client.clone(); 93 | if response.layers[0..n] 94 | .iter() 95 | .any(|l| l.digest == layer.digest) 96 | { 97 | continue; 98 | } 99 | 100 | layers_status.push(LayerDownloadStatus::Unknown(layer.digest.clone())); 101 | tokio::spawn(download_layer( 102 | layers_status.len() - 1, 103 | layer.digest.clone(), 104 | layer.clone(), 105 | client.clone(), 106 | tx.clone(), 107 | )); 108 | } 109 | 110 | loop { 111 | let progress = rx.recv().await.unwrap(); 112 | 113 | if progress.downloaded == progress.total { 114 | layers_status[progress.n] = LayerDownloadStatus::Completed(progress.digest); 115 | } else { 116 | layers_status[progress.n] = LayerDownloadStatus::Downloading( 117 | progress.digest, 118 | progress.downloaded, 119 | progress.total, 120 | ); 121 | } 122 | 123 | for status in &layers_status { 124 | print!("\x1B[K"); 125 | match status { 126 | LayerDownloadStatus::Unknown(digest) => println!("{}: unknown", digest), 127 | LayerDownloadStatus::Downloading(digest, downloaded, total) => println!( 128 | "{}: {}/{} ({:.2}%)", 129 | digest, 130 | downloaded / 1024, 131 | total / 1024, 132 | *downloaded as f32 / *total as f32 * 100f32 133 | ), 134 | LayerDownloadStatus::Completed(digest) => println!("{}: completed", digest), 135 | } 136 | } 137 | 138 | if layers_status.iter().all(|s| s.completed()) { 139 | break; 140 | } 141 | 142 | print!("\x1B[{}A", layers_status.len()); 143 | } 144 | 145 | Ok(()) 146 | } 147 | -------------------------------------------------------------------------------- /src/manifest.rs: -------------------------------------------------------------------------------- 1 | //! Image manifest structs. 2 | //! 3 | //! See [Imag Manifest V2, Schema 2](https://docs.docker.com/registry/spec/manifest-v2-2/) 4 | //! for more details. 5 | 6 | use serde::{de, ser}; 7 | use sha2::digest::generic_array::{typenum, GenericArray}; 8 | use std::{collections::HashMap, error::Error, fmt, str}; 9 | 10 | /// The [`ManifestList`] is the "fat manifest" which points 11 | /// to specific image manifests for one or more platforms. 12 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 13 | #[serde(rename_all = "camelCase")] 14 | pub struct ManifestList { 15 | pub schema_version: i32, 16 | pub media_type: String, 17 | pub manifests: Vec, 18 | } 19 | 20 | /// [`ManifestItem`] for a specific platform. 21 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 22 | #[serde(rename_all = "camelCase")] 23 | pub struct ManifestItem { 24 | pub media_type: String, 25 | pub size: usize, 26 | pub digest: Digest, 27 | pub platform: Platform, 28 | } 29 | 30 | /// The [`Platform`] describes the platform which the image in the 31 | /// manifest runs on. 32 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 33 | #[serde(rename_all = "camelCase")] 34 | pub struct Platform { 35 | pub architecture: String, 36 | pub os: String, 37 | pub os_version: Option, 38 | pub os_features: Option>, 39 | pub variant: Option, 40 | pub features: Option>, 41 | } 42 | 43 | /// The [`Manifest`] provides a configuration and a set of layers for a 44 | /// container image. 45 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 46 | #[serde(rename_all = "camelCase")] 47 | pub struct Manifest { 48 | pub schema_version: i32, 49 | pub media_type: String, 50 | pub config: ManifestConfig, 51 | pub layers: Vec, 52 | } 53 | 54 | /// The [`ManifestConfig`] references a configuration object for a container. 55 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 56 | #[serde(rename_all = "camelCase")] 57 | pub struct ManifestConfig { 58 | pub media_type: String, 59 | pub size: usize, 60 | pub digest: Digest, 61 | } 62 | 63 | /// The [`Layer`] references a [`crate::blob::Blob`] by digest. 64 | #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] 65 | #[serde(rename_all = "camelCase")] 66 | pub struct Layer { 67 | pub media_type: String, 68 | pub size: usize, 69 | pub digest: Digest, 70 | } 71 | 72 | /// Image configuration. 73 | /// 74 | /// Describes some basic information about the image such as date 75 | /// created, author, as well as execution/runtime configuration like 76 | /// entrypoint, default arguments, networking and volumes. 77 | #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] 78 | #[serde(rename_all = "camelCase")] 79 | pub struct Image { 80 | pub architecture: String, 81 | pub os: String, 82 | pub created: Option, 83 | pub author: Option, 84 | pub config: Option, 85 | pub rootfs: RootFS, 86 | pub history: Option>, 87 | } 88 | 89 | /// Image execution default parameters. 90 | #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] 91 | #[serde(rename_all = "PascalCase")] 92 | pub struct ImageConfig { 93 | pub user: Option, 94 | pub exposed_ports: Option>, 95 | pub env: Option>, 96 | pub entrypoint: Option>, 97 | pub cmd: Option>, 98 | pub volumes: Option>, 99 | pub working_dir: Option, 100 | pub labels: Option>, 101 | pub stop_signal: Option, 102 | } 103 | 104 | #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] 105 | pub struct RootFS { 106 | pub r#type: String, 107 | diff_ids: Vec, 108 | } 109 | 110 | /// Describe the history of a layer. 111 | #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] 112 | pub struct LayerHistory { 113 | pub created: Option, 114 | pub author: Option, 115 | pub created_by: Option, 116 | pub comment: Option, 117 | pub empty_layer: Option, 118 | } 119 | 120 | /// Content identifier. 121 | #[derive(Clone, Debug, PartialEq)] 122 | pub struct Digest { 123 | pub algorithm: String, 124 | pub hash: String, 125 | } 126 | 127 | impl Digest { 128 | pub fn from_sha256(hash: GenericArray) -> Self { 129 | Self { 130 | algorithm: "sha256".to_owned(), 131 | hash: format!("{:x}", hash), 132 | } 133 | } 134 | } 135 | 136 | impl fmt::Display for Digest { 137 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 138 | write!(f, "{}:{}", &self.algorithm, &self.hash) 139 | } 140 | } 141 | 142 | impl str::FromStr for Digest { 143 | type Err = ParseDigestError; 144 | 145 | fn from_str(s: &str) -> Result { 146 | let mut split_it = s.splitn(2, ':'); 147 | let algorithm = split_it.next().ok_or(ParseDigestError)?; 148 | let hash = split_it.next().ok_or(ParseDigestError)?; 149 | 150 | Ok(Digest { 151 | algorithm: algorithm.to_owned(), 152 | hash: hash.to_owned(), 153 | }) 154 | } 155 | } 156 | 157 | #[derive(Debug, PartialEq)] 158 | pub struct ParseDigestError; 159 | 160 | impl fmt::Display for ParseDigestError { 161 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 162 | write!(f, "invalid digest format") 163 | } 164 | } 165 | 166 | impl Error for ParseDigestError {} 167 | 168 | impl<'de> de::Deserialize<'de> for Digest { 169 | fn deserialize(deserializer: D) -> Result 170 | where 171 | D: de::Deserializer<'de>, 172 | { 173 | let s = String::deserialize(deserializer)?; 174 | s.parse().map_err(de::Error::custom) 175 | } 176 | } 177 | 178 | impl ser::Serialize for Digest { 179 | fn serialize(&self, serializer: S) -> Result 180 | where 181 | S: ser::Serializer, 182 | { 183 | let val = format!("{}:{}", &self.hash, &self.algorithm); 184 | serializer.serialize_str(val.as_str()) 185 | } 186 | } 187 | --------------------------------------------------------------------------------