g1t/services/packages/src/manifest.rs

269 lines11,268 bytesCodeBlame
1//! Reading the manifests a client pushes: Docker's image manifest and
2//! manifest list, and OCI's image manifest and index, artifacts and their
3//! `subject` included.
4
5use serde::Deserialize;
6use serde_json::Value;
7
8use crate::digest::Digest;
9
10pub const DOCKER_MANIFEST: &str = "application/vnd.docker.distribution.manifest.v2+json";
11pub const DOCKER_LIST: &str = "application/vnd.docker.distribution.manifest.list.v2+json";
12pub const OCI_MANIFEST: &str = "application/vnd.oci.image.manifest.v1+json";
13pub const OCI_INDEX: &str = "application/vnd.oci.image.index.v1+json";
14/// The config of an artifact that has none of its own.
15const OCI_EMPTY: &str = "application/vnd.oci.empty.v1+json";
16/// The most a manifest may be, as other registries allow.
17pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
18
19/// Whether a manifest lists images (a manifest list or index) or is one.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum Kind {
22 Image,
23 Index,
24}
25
26/// Something a manifest refers to by digest.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct Reference {
29 pub digest: Digest,
30 pub size: u64,
31 pub media_type: Option<String>,
32 /// `config` or `layer`, for an image's blobs; the platform, for an
33 /// index's manifests.
34 pub role: String,
35}
36
37/// A manifest as read.
38#[derive(Clone, Debug, PartialEq)]
39pub struct Manifest {
40 pub media_type: String,
41 pub kind: Kind,
42 /// The blobs an image manifest names: its config, then its layers.
43 pub blobs: Vec<Reference>,
44 /// The manifests an index names.
45 pub manifests: Vec<Reference>,
46 pub subject: Option<Digest>,
47 /// What an artifact is: `artifactType`, or an image's config's type.
48 pub artifact_type: Option<String>,
49 pub annotations: Option<Value>,
50 /// For an index: `os/architecture[/variant]` of each manifest that says.
51 pub platforms: Vec<String>,
52}
53
54#[derive(Deserialize)]
55#[serde(rename_all = "camelCase")]
56struct Descriptor {
57 media_type: Option<String>,
58 digest: String,
59 size: u64,
60 #[serde(default)]
61 platform: Option<Platform>,
62}
63
64#[derive(Deserialize)]
65struct Platform {
66 architecture: String,
67 os: String,
68 #[serde(default)]
69 variant: Option<String>,
70}
71
72impl Platform {
73 fn name(&self) -> String {
74 match &self.variant {
75 Some(variant) if !variant.is_empty() => format!("{}/{}/{variant}", self.os, self.architecture),
76 _ => format!("{}/{}", self.os, self.architecture),
77 }
78 }
79}
80
81#[derive(Deserialize)]
82#[serde(rename_all = "camelCase")]
83struct Raw {
84 #[serde(default)]
85 schema_version: Option<u32>,
86 #[serde(default)]
87 media_type: Option<String>,
88 #[serde(default)]
89 artifact_type: Option<String>,
90 #[serde(default)]
91 config: Option<Descriptor>,
92 #[serde(default)]
93 layers: Option<Vec<Descriptor>>,
94 #[serde(default)]
95 manifests: Option<Vec<Descriptor>>,
96 #[serde(default)]
97 subject: Option<Descriptor>,
98 #[serde(default)]
99 annotations: Option<Value>,
100}
101
102/// Why a manifest was refused, as the registry's error codes say it.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub enum Refused {
105 /// Not a manifest this registry reads (`MANIFEST_INVALID`).
106 Invalid(String),
107 /// Docker's schema 1, long retired (`UNSUPPORTED`).
108 Unsupported(String),
109}
110
111fn reference(descriptor: Descriptor, role: String) -> Result<Reference, Refused> {
112 let digest = Digest::parse(&descriptor.digest)
113 .ok_or_else(|| Refused::Invalid(format!("{} is not a sha256 digest.", descriptor.digest)))?;
114 Ok(Reference { digest, size: descriptor.size, media_type: descriptor.media_type, role })
115}
116
117/// Reads a manifest. `content_type` is the request's, which wins over the
118/// manifest's own `mediaType` when both are given, as the spec says.
119pub fn parse(bytes: &[u8], content_type: Option<&str>) -> Result<Manifest, Refused> {
120 if bytes.len() > MAX_MANIFEST_BYTES {
121 return Err(Refused::Invalid(format!("A manifest is at most {} MiB.", MAX_MANIFEST_BYTES / 1024 / 1024)));
122 }
123 let raw: Raw = serde_json::from_slice(bytes).map_err(|error| Refused::Invalid(format!("The manifest is not valid JSON: {error}.")))?;
124 let given = content_type
125 .map(|value| value.split(';').next().unwrap_or(value).trim().to_owned())
126 .filter(|value| !value.is_empty() && value != "application/json" && value != "application/octet-stream");
127 let media_type = given.or(raw.media_type.clone()).unwrap_or_default();
128 if media_type.starts_with("application/vnd.docker.distribution.manifest.v1+") || raw.schema_version == Some(1) {
129 return Err(Refused::Unsupported("Docker image manifests of schema 1 are not accepted; push with a current client.".to_owned()));
130 }
131 let kind = match media_type.as_str() {
132 DOCKER_MANIFEST | OCI_MANIFEST => Kind::Image,
133 DOCKER_LIST | OCI_INDEX => Kind::Index,
134 // An OCI manifest may leave its media type out: what it holds says.
135 "" if raw.manifests.is_some() => Kind::Index,
136 "" if raw.config.is_some() => Kind::Image,
137 other => return Err(Refused::Invalid(format!("Manifests of type {other} are not accepted."))),
138 };
139 let media_type = match (media_type.as_str(), kind) {
140 ("", Kind::Image) => OCI_MANIFEST.to_owned(),
141 ("", Kind::Index) => OCI_INDEX.to_owned(),
142 _ => media_type,
143 };
144 let subject = match raw.subject {
145 Some(subject) => Some(reference(subject, "subject".to_owned())?.digest),
146 None => None,
147 };
148 let mut manifest = Manifest {
149 media_type,
150 kind,
151 blobs: Vec::new(),
152 manifests: Vec::new(),
153 subject,
154 artifact_type: raw.artifact_type.filter(|t| !t.is_empty()),
155 annotations: raw.annotations,
156 platforms: Vec::new(),
157 };
158 match kind {
159 Kind::Image => {
160 let config = raw.config.ok_or_else(|| Refused::Invalid("An image manifest names its config.".to_owned()))?;
161 // What a referrer is, when it says only through its config.
162 if manifest.artifact_type.is_none() && manifest.subject.is_some() {
163 manifest.artifact_type = config.media_type.clone().filter(|t| t != OCI_EMPTY);
164 }
165 manifest.blobs.push(reference(config, "config".to_owned())?);
166 for layer in raw.layers.unwrap_or_default() {
167 manifest.blobs.push(reference(layer, "layer".to_owned())?);
168 }
169 }
170 Kind::Index => {
171 for entry in raw.manifests.unwrap_or_default() {
172 let platform = entry.platform.as_ref().map(Platform::name);
173 if let Some(platform) = &platform
174 && !platform.starts_with("unknown/")
175 && !manifest.platforms.contains(platform)
176 {
177 manifest.platforms.push(platform.clone());
178 }
179 manifest.manifests.push(reference(entry, platform.unwrap_or_default())?);
180 }
181 }
182 }
183 Ok(manifest)
184}
185
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use serde_json::json;
191
192 fn digest(c: char) -> String {
193 format!("sha256:{}", c.to_string().repeat(64))
194 }
195
196 #[test]
197 fn an_image_names_its_config_then_its_layers() {
198 let body = json!({
199 "schemaVersion": 2,
200 "mediaType": DOCKER_MANIFEST,
201 "config": { "mediaType": "application/vnd.docker.container.image.v1+json", "digest": digest('a'), "size": 10 },
202 "layers": [{ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", "digest": digest('b'), "size": 20 }],
203 });
204 let manifest = parse(body.to_string().as_bytes(), Some(DOCKER_MANIFEST)).unwrap();
205 assert_eq!(manifest.kind, Kind::Image);
206 assert_eq!(manifest.blobs.len(), 2);
207 assert_eq!(manifest.blobs[0].role, "config");
208 assert_eq!(manifest.blobs[1].size, 20);
209 assert_eq!(manifest.subject, None);
210 }
211
212 #[test]
213 fn an_index_names_its_manifests_and_platforms() {
214 let body = json!({
215 "schemaVersion": 2,
216 "mediaType": OCI_INDEX,
217 "manifests": [
218 { "mediaType": OCI_MANIFEST, "digest": digest('a'), "size": 1, "platform": { "os": "linux", "architecture": "amd64" } },
219 { "mediaType": OCI_MANIFEST, "digest": digest('b'), "size": 1, "platform": { "os": "linux", "architecture": "arm64", "variant": "v8" } },
220 { "mediaType": OCI_MANIFEST, "digest": digest('c'), "size": 1, "platform": { "os": "unknown", "architecture": "unknown" } },
221 ],
222 });
223 // Sent as plain JSON: the manifest's own type says what it is.
224 let manifest = parse(body.to_string().as_bytes(), Some("application/json")).unwrap();
225 assert_eq!(manifest.kind, Kind::Index);
226 assert_eq!(manifest.manifests.len(), 3);
227 assert_eq!(manifest.platforms, ["linux/amd64", "linux/arm64/v8"]);
228 }
229
230 #[test]
231 fn an_artifact_names_its_subject_and_type() {
232 let body = json!({
233 "schemaVersion": 2,
234 "mediaType": OCI_MANIFEST,
235 "artifactType": "application/vnd.example.sbom",
236 "config": { "mediaType": OCI_EMPTY, "digest": digest('e'), "size": 2 },
237 "layers": [],
238 "subject": { "mediaType": OCI_MANIFEST, "digest": digest('a'), "size": 100 },
239 "annotations": { "org.example": "yes" },
240 });
241 let manifest = parse(body.to_string().as_bytes(), None).unwrap();
242 assert_eq!(manifest.subject.unwrap().as_str(), digest('a'));
243 assert_eq!(manifest.artifact_type.as_deref(), Some("application/vnd.example.sbom"));
244 assert_eq!(manifest.annotations.unwrap()["org.example"], "yes");
245 // Without artifactType, a referrer's config type is what it is.
246 let typed = json!({
247 "schemaVersion": 2,
248 "config": { "mediaType": "application/vnd.example.sig", "digest": digest('e'), "size": 2 },
249 "layers": [],
250 "subject": { "mediaType": OCI_MANIFEST, "digest": digest('a'), "size": 100 },
251 });
252 let manifest = parse(typed.to_string().as_bytes(), None).unwrap();
253 assert_eq!(manifest.media_type, OCI_MANIFEST);
254 assert_eq!(manifest.artifact_type.as_deref(), Some("application/vnd.example.sig"));
255 }
256
257 #[test]
258 fn what_is_not_a_manifest_is_refused() {
259 assert!(matches!(parse(b"not json", None), Err(Refused::Invalid(_))));
260 let v1 = json!({ "schemaVersion": 1, "name": "x", "fsLayers": [] });
261 assert!(matches!(parse(v1.to_string().as_bytes(), None), Err(Refused::Unsupported(_))));
262 let no_config = json!({ "schemaVersion": 2, "mediaType": OCI_MANIFEST, "layers": [] });
263 assert!(matches!(parse(no_config.to_string().as_bytes(), None), Err(Refused::Invalid(_))));
264 let bad_digest = json!({ "schemaVersion": 2, "mediaType": OCI_MANIFEST, "config": { "digest": "md5:x", "size": 1 } });
265 assert!(matches!(parse(bad_digest.to_string().as_bytes(), None), Err(Refused::Invalid(_))));
266 let other = json!({ "schemaVersion": 2, "mediaType": "text/plain" });
267 assert!(matches!(parse(other.to_string().as_bytes(), Some("text/plain")), Err(Refused::Invalid(_))));
268 }
269}