1use std::io::Read;
24
25use anyhow::{Context, Result, bail, ensure};
26use camino::{Utf8Path, Utf8PathBuf};
27use cap_std_ext::cap_std;
28use fn_error_context::context;
29use ocidir::oci_spec::image::{
30 Descriptor, Digest, DigestAlgorithm, ImageConfiguration, ImageManifest, MediaType,
31};
32use ocidir::prelude::*;
33use ocidir::{OciArchive, OciDir};
34use ostree_ext::containers_image_proxy;
35
36use oci_delta::BlobStream;
37use oci_delta::{
38 BlobStreamFuture, DeltaBlobReader, MEDIA_TYPE_DELTA, ParsedDelta, is_delta_artifact,
39 parse_delta_manifest,
40};
41
42#[derive(Debug)]
47enum Layout {
48 Dir(OciDir),
49 Archive(OciArchive),
50}
51
52impl Layout {
53 fn open(path: &Utf8Path) -> Result<Self> {
54 if path.is_dir() {
55 let dir = cap_std::fs::Dir::open_ambient_dir(path, cap_std::ambient_authority())
56 .context("Opening OCI layout directory")?;
57 Ok(Self::Dir(
58 OciDir::open(dir).context("Reading OCI layout directory")?,
59 ))
60 } else {
61 Ok(Self::Archive(
62 OciArchive::open(path).context("Reading OCI archive")?,
63 ))
64 }
65 }
66
67 fn read_index(&self) -> Result<ocidir::oci_spec::image::ImageIndex> {
68 match self {
69 Self::Dir(o) => o.read_index(),
70 Self::Archive(o) => o.read_index(),
71 }
72 .map_err(Into::into)
73 }
74
75 fn read_blob(&self, desc: &Descriptor) -> Result<Box<dyn BlobStream>> {
76 let blob: Box<dyn BlobStream> = match self {
77 Self::Dir(o) => Box::new(o.read_blob(desc)?),
78 Self::Archive(o) => Box::new(o.read_blob(desc)?),
79 };
80 Ok(blob)
81 }
82}
83
84impl DeltaBlobReader for Layout {
85 fn open_blob(&self, desc: &Descriptor) -> BlobStreamFuture<'_> {
86 let result = self
87 .read_blob(desc)
88 .with_context(|| format!("Reading blob {}", desc.digest()));
89 Box::pin(std::future::ready(result))
90 }
91}
92
93#[derive(Debug)]
95pub(crate) struct Delta {
96 pub(crate) path: Utf8PathBuf,
98 pub(crate) parsed: ParsedDelta,
100 layout: Layout,
101}
102
103impl Delta {
104 #[context("Opening delta {path}")]
107 pub(crate) async fn open(path: &Utf8Path) -> Result<Self> {
108 let layout = Layout::open(path)?;
109 let parsed = parse(&layout).await?;
110 Ok(Self {
111 path: path.to_owned(),
112 parsed,
113 layout,
114 })
115 }
116
117 pub(crate) fn read_patch(&self, desc: &Descriptor) -> Result<Box<dyn BlobStream>> {
119 self.layout
120 .read_blob(desc)
121 .with_context(|| format!("Reading delta patch {}", desc.digest()))
122 }
123
124 pub(crate) fn target_manifest_digest(&self) -> &Digest {
126 self.parsed.target_manifest_descriptor.digest()
127 }
128
129 pub(crate) fn validate_image_reference(
130 &self,
131 imgref: &crate::spec::ImageReference,
132 ) -> Result<()> {
133 if imgref.transport()? != containers_image_proxy::Transport::Registry {
134 return Ok(());
135 }
136 let reference: ocidir::oci_spec::distribution::Reference = imgref
137 .image
138 .parse()
139 .with_context(|| format!("Parsing image reference {}", imgref.image))?;
140 if let Some(digest) = reference.digest() {
141 let digest: Digest = digest.parse()?;
142 ensure!(
143 &digest == self.target_manifest_digest(),
144 "Image reference {} names digest {digest}, but delta {} targets {}",
145 imgref.image,
146 self.path,
147 self.target_manifest_digest(),
148 );
149 }
150 Ok(())
151 }
152
153 pub(crate) fn target_manifest(&self) -> &ImageManifest {
155 &self.parsed.target_manifest
156 }
157
158 pub(crate) fn target_config(&self) -> &ImageConfiguration {
160 &self.parsed.target_config
161 }
162
163 pub(crate) fn source_config_digest(&self) -> &Digest {
165 &self.parsed.source_config_digest
166 }
167
168 pub(crate) fn pull_ref(&self) -> Result<containers_image_proxy::ImageReference> {
171 ensure!(
174 !self.path.as_str().contains(':'),
175 "Delta path {} contains a colon, which cannot be expressed as an image reference",
176 self.path,
177 );
178 let transport = if self.path.is_dir() {
179 "oci"
180 } else {
181 "oci-archive"
182 };
183 format!("{transport}:{}", self.path)
184 .as_str()
185 .try_into()
186 .map_err(|e| anyhow::anyhow!("Building image reference for {}: {e}", self.path))
187 }
188
189 pub(crate) fn describe(&self) -> String {
191 let total = self.parsed.target_manifest.layers().len();
192 let patched = self.parsed.delta_layer_by_to.len();
193 format!(
194 "{}: target manifest {}, {total} layers ({patched} patched, {} reused from source config {})",
195 self.path,
196 self.target_manifest_digest(),
197 total - patched,
198 self.parsed.source_config_digest,
199 )
200 }
201}
202
203pub(crate) async fn open_opt(path: Option<&Utf8Path>) -> Result<Option<Delta>> {
205 match path {
206 Some(path) => Ok(Some(Delta::open(path).await?)),
207 None => Ok(None),
208 }
209}
210
211pub(crate) fn reject_unified_storage(delta: &Delta, use_unified: bool) -> Result<()> {
213 ensure!(
214 !use_unified,
215 "Cannot apply delta {}: deltas with unified storage not supported.",
216 delta.path,
217 );
218 Ok(())
219}
220
221async fn parse(oci: &Layout) -> Result<ParsedDelta> {
223 let index = oci.read_index().context("Reading index")?;
224 let [desc] = index.manifests().as_slice() else {
225 bail!(
226 "Expected an OCI layout with a single manifest, found {}; this is not a delta",
227 index.manifests().len()
228 );
229 };
230 ensure!(
231 desc.media_type() == &MediaType::ImageManifest,
232 "Expected an image manifest, found {}; this is not a delta",
233 desc.media_type(),
234 );
235
236 let raw = read_verified(
237 "Delta manifest",
238 oci.read_blob(desc).context("Reading delta manifest")?,
239 desc.digest(),
240 )
241 .context("Reading delta manifest")?;
242 let manifest: ImageManifest = serde_json::from_slice(&raw).context("Parsing delta manifest")?;
243
244 ensure!(
245 is_delta_artifact(&manifest),
246 "Not a delta: expected artifactType {MEDIA_TYPE_DELTA}, found {}.",
247 manifest
248 .artifact_type()
249 .as_ref()
250 .map(|t| t.to_string())
251 .unwrap_or_else(|| "none".into()),
252 );
253
254 parse_delta_manifest(&manifest, oci).await
255}
256
257fn read_verified(what: &str, mut reader: impl Read, expected: &Digest) -> Result<Vec<u8>> {
259 let mut data = Vec::new();
260 reader.read_to_end(&mut data)?;
261 let algorithm = match expected.algorithm() {
262 DigestAlgorithm::Sha256 => openssl::hash::MessageDigest::sha256(),
263 DigestAlgorithm::Sha384 => openssl::hash::MessageDigest::sha384(),
264 DigestAlgorithm::Sha512 => openssl::hash::MessageDigest::sha512(),
265 other => bail!("{what} uses unsupported digest algorithm {other}"),
266 };
267 let found = hex::encode(openssl::hash::hash(algorithm, &data)?);
268 ensure!(
269 found == expected.digest(),
270 "{what} does not match its digest: expected {expected}, got {found}",
271 );
272 Ok(data)
273}
274
275#[cfg(test)]
276pub(crate) mod tests {
277 use super::*;
278 use ocidir::oci_spec::image::{ImageConfigurationBuilder, ImageManifestBuilder, RootFsBuilder};
279 use std::collections::HashMap;
280
281 pub(crate) const DELTA_CONTENT: &str = "io.github.containers.delta.content";
282 pub(crate) const DELTA_TO: &str = "io.github.containers.delta.to";
283 pub(crate) const DELTA_SOURCE_CONFIG: &str = "io.github.containers.delta.source-config";
284 const TAR_DIFF: &str = "application/vnd.tar-diff";
285 const ZERO_DIGEST: &str =
286 "sha256:0000000000000000000000000000000000000000000000000000000000000000";
287
288 pub(crate) fn blob(oci: &OciDir, data: &[u8], media_type: MediaType) -> Descriptor {
289 let mut w = oci.create_blob().unwrap();
290 std::io::Write::write_all(&mut w, data).unwrap();
291 w.complete()
292 .unwrap()
293 .descriptor()
294 .media_type(media_type)
295 .build()
296 .unwrap()
297 }
298
299 fn image_config(diff_ids: &[&str]) -> ImageConfiguration {
300 ImageConfigurationBuilder::default()
301 .rootfs(
302 RootFsBuilder::default()
303 .typ("layers")
304 .diff_ids(diff_ids.iter().map(|s| s.to_string()).collect::<Vec<_>>())
305 .build()
306 .unwrap(),
307 )
308 .build()
309 .unwrap()
310 }
311
312 pub(crate) fn annotate(mut desc: Descriptor, annotations: &[(&str, &str)]) -> Descriptor {
313 desc.set_annotations(Some(
314 annotations
315 .iter()
316 .map(|(k, v)| (k.to_string(), v.to_string()))
317 .collect::<HashMap<_, _>>(),
318 ));
319 desc
320 }
321
322 pub(crate) struct TestDelta {
325 oci: OciDir,
326 tmp: tempfile::TempDir,
327 manifest_desc: Descriptor,
328 config_desc: Descriptor,
329 layer_descs: Vec<Descriptor>,
330 }
331
332 impl TestDelta {
333 pub(crate) fn new() -> Self {
334 Self::with_diff_ids(2)
335 }
336
337 fn with_diff_ids(n_diff_ids: usize) -> Self {
339 let tmp = tempfile::tempdir().unwrap();
340 let dir = cap_std::fs::Dir::open_ambient_dir(tmp.path(), cap_std::ambient_authority())
341 .unwrap();
342 let oci = OciDir::ensure(dir).unwrap();
343
344 let layer_descs: Vec<Descriptor> = ["layer-one", "layer-two"]
345 .iter()
346 .map(|d| blob(&oci, d.as_bytes(), MediaType::ImageLayerGzip))
347 .collect();
348 let diff_ids = (0..n_diff_ids)
349 .map(|i| format!("sha256:{}", i.to_string().repeat(64)))
350 .collect::<Vec<_>>();
351 let config = image_config(&diff_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>());
352 let config_desc = blob(
353 &oci,
354 serde_json::to_vec(&config).unwrap().as_slice(),
355 MediaType::ImageConfig,
356 );
357
358 let manifest = ImageManifestBuilder::default()
359 .schema_version(2u32)
360 .media_type(MediaType::ImageManifest)
361 .config(config_desc.clone())
362 .layers(layer_descs.clone())
363 .build()
364 .unwrap();
365 let manifest_desc = blob(
366 &oci,
367 serde_json::to_vec(&manifest).unwrap().as_slice(),
368 MediaType::ImageManifest,
369 );
370
371 Self {
372 oci,
373 tmp,
374 manifest_desc,
375 config_desc,
376 layer_descs,
377 }
378 }
379
380 pub(crate) fn path(&self) -> &Utf8Path {
381 Utf8Path::from_path(self.tmp.path()).unwrap()
382 }
383
384 fn finish(&self, artifact_type: Option<&str>, layers: Vec<Descriptor>) {
386 let empty = blob(&self.oci, b"{}", MediaType::EmptyJSON);
387 let mut b = ImageManifestBuilder::default()
388 .schema_version(2u32)
389 .media_type(MediaType::ImageManifest)
390 .config(empty)
391 .layers(layers)
392 .annotations(HashMap::from([(
393 DELTA_SOURCE_CONFIG.to_string(),
394 ZERO_DIGEST.to_string(),
395 )]));
396 if let Some(t) = artifact_type {
397 b = b.artifact_type(MediaType::Other(t.to_string()));
398 }
399 self.oci
400 .replace_with_single_manifest(b.build().unwrap(), Default::default())
401 .unwrap();
402 }
403
404 pub(crate) fn finish_default(&self) {
407 self.finish(Some(MEDIA_TYPE_DELTA), self.default_layers());
408 }
409
410 fn patch_for(&self, to: &str) -> Descriptor {
411 let patch = blob(
412 &self.oci,
413 b"patch-data",
414 MediaType::Other(TAR_DIFF.to_string()),
415 );
416 annotate(patch, &[(DELTA_CONTENT, "image-layer"), (DELTA_TO, to)])
417 }
418
419 fn default_layers(&self) -> Vec<Descriptor> {
420 vec![
421 annotate(
422 self.manifest_desc.clone(),
423 &[(DELTA_CONTENT, "image-manifest")],
424 ),
425 annotate(self.config_desc.clone(), &[(DELTA_CONTENT, "image-config")]),
426 self.patch_for(self.layer_descs.last().unwrap().digest().as_ref()),
427 ]
428 }
429
430 fn corrupt_blob(&self, desc: &Descriptor) {
433 let name = desc.digest().digest();
434 let mut content = Vec::new();
435 std::io::Read::read_to_end(&mut self.oci.blobs_dir().open(name).unwrap(), &mut content)
436 .unwrap();
437 let pos = content
440 .windows(7)
441 .position(|w| w == b"sha256:")
442 .expect("no digest in blob")
443 + 7
444 + 63;
445 content[pos] = if content[pos] == b'0' { b'1' } else { b'0' };
446 self.oci.blobs_dir().write(name, &content).unwrap();
447 }
448 }
449
450 #[tokio::test]
451 async fn test_valid_delta() {
452 let t = TestDelta::new();
453 t.finish_default();
454
455 let delta = Delta::open(t.path()).await.unwrap();
456 assert_eq!(delta.target_manifest_digest(), t.manifest_desc.digest());
457 assert_eq!(delta.parsed.target_manifest.layers().len(), 2);
458 assert_eq!(delta.parsed.delta_layer_by_to.len(), 1);
459 assert!(delta.describe().contains("1 patched, 1 reused"));
460
461 let pull_ref = delta.pull_ref().unwrap();
462 assert_eq!(
463 pull_ref.transport,
464 ostree_ext::containers_image_proxy::Transport::OciDir
465 );
466 assert_eq!(pull_ref.name, t.path().as_str());
467 }
468
469 #[tokio::test]
470 async fn test_rejects_invalid() {
471 struct Case {
472 name: &'static str,
473 prepare: fn(&TestDelta),
474 expected: &'static str,
475 }
476 let cases = [
477 Case {
478 name: "plain image, no artifactType",
479 prepare: |t| t.finish(None, t.layer_descs.clone()),
480 expected: "Not a delta",
481 },
482 Case {
483 name: "patch for a layer not in the target image",
484 prepare: |t| {
485 let mut layers = t.default_layers();
486 layers.pop();
487 layers.push(t.patch_for(ZERO_DIGEST));
488 t.finish(Some(MEDIA_TYPE_DELTA), layers);
489 },
490 expected: "not part of the target image",
491 },
492 Case {
493 name: "embedded manifest altered after the fact",
494 prepare: |t| {
495 t.finish_default();
496 t.corrupt_blob(&t.manifest_desc);
497 },
498 expected: "does not match its digest",
499 },
500 Case {
501 name: "delta manifest altered after the fact",
502 prepare: |t| {
503 t.finish_default();
504 let index = t.oci.read_index().unwrap();
505 t.corrupt_blob(&index.manifests()[0]);
506 },
507 expected: "does not match its digest",
508 },
509 Case {
510 name: "embedded config is not the one the manifest references",
511 prepare: |t| {
512 let other = blob(
513 &t.oci,
514 &serde_json::to_vec(&image_config(&["sha256:aa", "sha256:bb"])).unwrap(),
515 MediaType::ImageConfig,
516 );
517 let mut layers = t.default_layers();
518 layers[1] = annotate(other, &[(DELTA_CONTENT, "image-config")]);
519 t.finish(Some(MEDIA_TYPE_DELTA), layers);
520 },
521 expected: "references config",
522 },
523 ];
524 for case in cases {
525 let t = TestDelta::new();
526 (case.prepare)(&t);
527 let err = Delta::open(t.path())
528 .await
529 .err()
530 .unwrap_or_else(|| panic!("{}: expected failure", case.name));
531 assert!(
532 format!("{err:#}").contains(case.expected),
533 "{}: unexpected error: {err:#}",
534 case.name
535 );
536 }
537 }
538
539 #[tokio::test]
540 async fn test_rejects_diff_id_mismatch() {
541 let t = TestDelta::with_diff_ids(1);
542 t.finish_default();
543
544 let err = Delta::open(t.path()).await.unwrap_err();
545 assert!(
546 format!("{err:#}").contains("1 diff_ids but 2 layers"),
547 "unexpected error: {err:#}"
548 );
549 }
550
551 #[tokio::test]
552 async fn test_pull_ref() {
553 let t = TestDelta::new();
554 t.finish_default();
555 let mut delta = Delta::open(t.path()).await.unwrap();
556
557 delta.path = "/var/tmp/update.oci-delta".into();
559 let pull_ref = delta.pull_ref().unwrap();
560 assert_eq!(
561 pull_ref.transport,
562 ostree_ext::containers_image_proxy::Transport::OciArchive
563 );
564 assert_eq!(pull_ref.name, "/var/tmp/update.oci-delta");
565
566 delta.path = "/var/tmp/a:b.oci-delta".into();
567 let err = delta.pull_ref().unwrap_err();
568 assert!(
569 format!("{err:#}").contains("contains a colon"),
570 "unexpected error: {err:#}"
571 );
572 }
573
574 #[tokio::test]
575 async fn test_validate_image_reference() {
576 let t = TestDelta::new();
577 t.finish_default();
578 let delta = Delta::open(t.path()).await.unwrap();
579 let target = delta.target_manifest_digest();
580 for (image, valid) in [
581 ("quay.io/example/os:latest".to_owned(), true),
582 (format!("quay.io/example/os@{target}"), true),
583 (format!("quay.io/example/os:latest@{target}"), true),
584 (format!("quay.io/example/os@{ZERO_DIGEST}"), false),
585 (format!("quay.io/example/os:latest@{ZERO_DIGEST}"), false),
586 ] {
587 let imgref = crate::spec::ImageReference {
588 image,
589 transport: "registry".into(),
590 signature: None,
591 };
592 let result = delta.validate_image_reference(&imgref);
593 if valid {
594 result.unwrap();
595 } else {
596 let error = result.unwrap_err().to_string();
597 assert!(error.contains(ZERO_DIGEST), "{error}");
598 assert!(error.contains(&target.to_string()), "{error}");
599 }
600 }
601 }
602
603 #[test]
604 fn test_read_verified() {
605 let expected: Digest =
606 "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
607 .parse()
608 .unwrap();
609 assert_eq!(
610 read_verified("test", &b"hello"[..], &expected).unwrap(),
611 b"hello"
612 );
613 let err = read_verified("test", &b"goodbye"[..], &expected).unwrap_err();
614 assert!(format!("{err:#}").contains("does not match its digest"));
615 }
616}