Skip to main content

bootc_lib/
delta.rs

1//! Support for oci-delta artifacts (`--from-delta`).
2//!
3//! An oci-delta is an OCI layout (normally packed as an uncompressed tar)
4//! whose single manifest is an artifact rather than an image. It carries the
5//! target image manifest and config verbatim, plus binary patches for the
6//! layers that changed relative to a source image. Layers that did not change
7//! are omitted entirely and are expected to already be present locally.
8//!
9//! See <https://github.com/containers/oci-delta> for the format.
10//!
11//! # Trust model
12//!
13//! Everything in a delta is verified against a single value, the target
14//! manifest digest: the embedded manifest and config must hash to the digests
15//! the delta records, the config must be the one the manifest references, and
16//! each reconstructed layer is checked against the corresponding diff_id in
17//! that config.
18//!
19//! What is not currently established is that the digest is the one you meant to
20//! deploy: it comes from the file rather than from a registry, and nothing
21//! signs it. Future work may use signatures to complete this.
22
23use 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/// The OCI layout a delta was read from.
43///
44/// Kept open for the lifetime of the [`Delta`] so that patch blobs can be read
45/// back when the layers are applied, without parsing the layout again.
46#[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/// A validated oci-delta artifact, ready to be applied.
94#[derive(Debug)]
95pub(crate) struct Delta {
96    /// Where this was loaded from; used in diagnostics.
97    pub(crate) path: Utf8PathBuf,
98    /// Parsed information about the delta.
99    pub(crate) parsed: ParsedDelta,
100    layout: Layout,
101}
102
103impl Delta {
104    /// Open and validate the delta at `path`, which may be an OCI layout
105    /// directory or (usually) an uncompressed tar of one.
106    #[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    /// Open the patch blob `desc`
118    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    /// The digest of the target image's manifest, as recorded in the delta.
125    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    /// The target image's manifest.
154    pub(crate) fn target_manifest(&self) -> &ImageManifest {
155        &self.parsed.target_manifest
156    }
157
158    /// The target image's config.
159    pub(crate) fn target_config(&self) -> &ImageConfiguration {
160        &self.parsed.target_config
161    }
162
163    /// The config digest of the image this delta was built against.
164    pub(crate) fn source_config_digest(&self) -> &Digest {
165        &self.parsed.source_config_digest
166    }
167
168    /// An image reference naming this delta as a local OCI layout, for handing
169    /// to the image import machinery.
170    pub(crate) fn pull_ref(&self) -> Result<containers_image_proxy::ImageReference> {
171        // An `oci:` reference is `path[:tag]`, so a colon in the path would be
172        // taken as a tag separator.
173        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    /// A one-line description of what applying this delta would do.
190    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
203/// Open the delta named by a `--from-delta` argument, if there is one.
204pub(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
211/// Reject `--from-delta` for an image that also has to be in containers-storage.
212pub(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
221/// Read the single manifest out of an OCI layout and parse it as a delta.
222async 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
257/// Read a blob and verify that it hashes to `expected`.
258fn 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    /// A minimal but structurally valid target image, of which the last layer
323    /// is the one the delta patches.
324    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        /// Two layers, but `n_diff_ids` diff_ids, to check invalid combinations
338        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        /// Write out the delta manifest with the given layers and artifactType.
385        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        /// Write out the standard delta: the embedded manifest, the embedded
405        /// config, and a patch for the last image layer.
406        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        /// Rewrite a blob in place, keeping its size so that ocidir's own size
431        /// check isn't what catches the alteration.
432        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            // Flip the last hex character of the first digest, which keeps the
438            // bytes both the same length and valid JSON.
439            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        // A path that is not a directory is taken to be an archive.
558        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}