Skip to main content

bootc_lib/
delta_ostree.rs

1//! Applying an oci-delta with the ostree backend.
2//!
3//! A tar-diff patch reads the source image by *path*, so the source does not
4//! have to be a container layer at all - an ostree commit holding the same
5//! filesystem works just as well. [`OstreeDataSource`] serves file content out
6//! of a commit, and [`DeltaLayerSource`] plugs that into the container importer
7//! in place of the image proxy.
8//!
9//! The one wrinkle is that ostree does not store an image's root filesystem
10//! verbatim: the tar importer moves `/etc` to `/usr/etc`, moves `/var` to
11//! `/usr/share/factory/var` on ostree older than v2024.3, and drops anything
12//! outside those unless the image was imported with `allow_nonusr`. See
13//! [`source_path_candidates`].
14//!
15//! An ostree-native (chunked) image additionally has its layers made of repo
16//! objects under `sysroot/ostree/`, which are nowhere to be found in the
17//! commit's own file tree. That is not a problem here only because oci-delta
18//! passes `IgnoreSourcePrefixes=["sysroot/ostree/"]` when building a delta, so
19//! no patch ever asks for one.
20
21use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
22use std::str::FromStr;
23use std::sync::{Arc, Mutex};
24
25use anyhow::{Context, Result, anyhow, bail, ensure};
26use cap_std_ext::cap_std::fs::Dir;
27use fn_error_context::context;
28use futures_util::future::BoxFuture;
29use ostree_ext::container as ostree_container;
30use ostree_ext::oci_spec::image as oci_image;
31use ostree_ext::prelude::*;
32use ostree_ext::{gio, ostree};
33use tokio::io::AsyncBufRead;
34use tokio::sync::OnceCell;
35use tokio_util::io::SyncIoBridge;
36
37use oci_delta::BlobStream;
38use oci_delta::{DeltaDataSource, reconstruct_layer_to};
39use ostree_container::{FetchedLayer, LayerSource};
40
41use crate::delta::Delta;
42
43/// How much reconstructed layer data may sit between the worker and the
44/// importer before the worker blocks.
45const PIPE_BUFFER: usize = 128 * 1024;
46
47/// Where in an ostree commit the content for a source image path may be found.
48///
49/// The first hit wins, and only the remaps the tar importer performs are tried.
50/// For `etc` that is unambiguous - an image cannot have both `/etc/passwd` and
51/// a distinct `/usr/etc/passwd` once imported, because the first becomes the
52/// second. For `var` it is merely very unlikely: on ostree v2024.3 and newer
53/// `/var` is kept as-is, so an image shipping both `/var/lib/x` and
54/// `/usr/share/factory/var/lib/x` would have the fallback read the wrong one if
55/// the former had been filtered out. A wrong read is caught by the diff_id
56/// check on the reconstructed layer.
57fn source_path_candidates(path: &str) -> Vec<String> {
58    let mut candidates = vec![path.to_owned()];
59    if let Some(rest) = path.strip_prefix("etc/") {
60        candidates.push(format!("usr/etc/{rest}"));
61    } else if let Some(rest) = path.strip_prefix("var/") {
62        candidates.push(format!("usr/share/factory/var/{rest}"));
63    }
64    candidates
65}
66
67trait ReadSeek: Read + Seek {}
68impl<T: Read + Seek> ReadSeek for T {}
69
70/// Serves file content from an ostree commit to a tar-diff patch.
71struct OstreeDataSource {
72    root: ostree::RepoFile,
73    objects: Dir,
74    current: Option<Box<dyn ReadSeek>>,
75}
76
77impl OstreeDataSource {
78    #[context("Opening delta source commit {commit}")]
79    fn new(repo: ostree::Repo, commit: &str) -> Result<Self> {
80        let (root, _) = repo.read_commit(commit, gio::Cancellable::NONE)?;
81        let root = root.downcast::<ostree::RepoFile>().expect("downcast");
82        let objects = Dir::reopen_dir(&repo.dfd_borrow())?
83            .open_dir("objects")
84            .context("Opening repo objects directory")?;
85        ensure!(
86            repo.mode() != ostree::RepoMode::Archive,
87            "OSTree archive repo mode is not supported"
88        );
89        Ok(Self {
90            root,
91            objects,
92            current: None,
93        })
94    }
95
96    fn open_object(&self, checksum: &str) -> Result<Box<dyn ReadSeek>> {
97        let (prefix, rest) = checksum.split_at(2);
98        let f = self.objects.open(format!("{prefix}/{rest}.file"))?;
99        return Ok(Box::new(f.into_std()));
100    }
101
102    fn current(&mut self) -> Result<&mut Box<dyn ReadSeek>> {
103        self.current
104            .as_mut()
105            .context("No current file set in data source")
106    }
107}
108
109impl DeltaDataSource for OstreeDataSource {
110    fn set_current_file(&mut self, path: &str) -> Result<()> {
111        self.current = None;
112        let path = path.trim_start_matches("./").trim_start_matches('/');
113        let cancellable = gio::Cancellable::NONE;
114        for candidate in source_path_candidates(path) {
115            let f = self.root.resolve_relative_path(&candidate);
116            let f = f.downcast::<ostree::RepoFile>().expect("downcast");
117            if f.query_file_type(gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, cancellable)
118                != gio::FileType::Regular
119            {
120                continue;
121            }
122            f.ensure_resolved()?;
123            self.current = Some(
124                self.open_object(f.checksum().as_str())
125                    .with_context(|| format!("Opening delta source file {candidate}"))?,
126            );
127            return Ok(());
128        }
129        bail!("Delta source file not found in source commit: {path}");
130    }
131
132    fn read_exact_current(&mut self, buf: &mut [u8]) -> Result<()> {
133        Ok(self.current()?.read_exact(buf)?)
134    }
135
136    fn seek_current(&mut self, offset: u64) -> Result<u64> {
137        Ok(self.current()?.seek(SeekFrom::Start(offset))?)
138    }
139
140    fn read_current_to_end(&mut self, max_size: u64) -> Result<Vec<u8>> {
141        let current = self.current()?;
142        let size = current.seek(SeekFrom::End(0))?;
143        ensure!(
144            size <= max_size,
145            "Source file too large: {size} > {max_size}"
146        );
147        current.seek(SeekFrom::Start(0))?;
148        let mut data = Vec::with_capacity(size as usize);
149        current.read_to_end(&mut data)?;
150        Ok(data)
151    }
152
153    fn copy_to(&mut self, dst: &mut dyn Write, n: u64) -> Result<()> {
154        let current = self.current()?;
155        let copied = std::io::copy(&mut Read::by_ref(current).take(n), dst)?;
156        if copied != n {
157            bail!("Short read from delta source: expected {n}, got {copied}");
158        }
159        Ok(())
160    }
161}
162
163/// Produces the target image's layers from an oci-delta and a source commit
164/// already in the repository, without any network access.
165#[derive(Debug)]
166pub(crate) struct DeltaLayerSource {
167    delta: Arc<Delta>,
168    /// `ostree::Repo` is `Send` but not `Sync`, and [`LayerSource`] is both.
169    repo: Mutex<ostree::Repo>,
170    source_commit: OnceCell<String>,
171}
172
173impl DeltaLayerSource {
174    pub(crate) fn new(delta: Arc<Delta>, repo: &ostree::Repo) -> Self {
175        Self {
176            delta,
177            repo: Mutex::new(repo.clone()),
178            source_commit: OnceCell::new(),
179        }
180    }
181
182    fn repo(&self) -> ostree::Repo {
183        self.repo.lock().unwrap().clone()
184    }
185
186    /// The commit to read source content from.
187    ///
188    /// Resolved on first use rather than up front: an import that turns out to
189    /// need no layers at all needs no source either, and this still runs before
190    /// anything is written.
191    async fn source_commit(&self) -> Result<&str> {
192        self.source_commit
193            .get_or_try_init(|| async { find_source_commit(&self.repo(), &self.delta) })
194            .await
195            .map(|s| s.as_str())
196    }
197
198    /// The patch for `layer`, its media type, and the diff_id the reconstructed
199    /// content must hash to.
200    fn open_patch(
201        &self,
202        manifest: &oci_image::ImageManifest,
203        layer: &oci_image::Descriptor,
204    ) -> Result<(Box<dyn BlobStream>, oci_image::MediaType, oci_image::Digest)> {
205        let Some(patch) = self.delta.parsed.delta_layer_by_to.get(layer.digest()) else {
206            bail!(
207                "Delta {} carries no patch for layer {}, and it is not present locally; \
208                 there is nothing to reconstruct it from",
209                self.delta.path,
210                layer.digest(),
211            );
212        };
213        let index = manifest
214            .layers()
215            .iter()
216            .position(|l| l == layer)
217            .ok_or_else(|| anyhow!("Layer {} is not part of the target image", layer.digest()))?;
218        let diff_id = self
219            .delta
220            .target_config()
221            .rootfs()
222            .diff_ids()
223            .get(index)
224            .ok_or_else(|| anyhow!("Target image has no diff_id for layer {index}"))?;
225        let diff_id = oci_image::Digest::from_str(diff_id)
226            .with_context(|| format!("Parsing diff_id for layer {index}"))?;
227        Ok((
228            self.delta.read_patch(patch)?,
229            patch.media_type().clone(),
230            diff_id,
231        ))
232    }
233}
234
235impl LayerSource for DeltaLayerSource {
236    fn fetch_layer<'a>(
237        &'a self,
238        manifest: &'a oci_image::ImageManifest,
239        layer: &'a oci_image::Descriptor,
240        // The reconstructed bytes are uncompressed, so counting them against
241        // the descriptor's compressed size would overshoot; the importer still
242        // reports per-layer start and completion.
243        _progress: Option<
244            &'a tokio::sync::watch::Sender<Option<ostree_container::store::LayerProgress>>,
245        >,
246    ) -> BoxFuture<'a, Result<FetchedLayer<'a>>> {
247        Box::pin(async move {
248            let (blob, media_type, diff_id) = self.open_patch(manifest, layer)?;
249            let source_commit = self.source_commit().await?.to_owned();
250            let repo = self.repo();
251            let (writer, reader) = tokio::io::duplex(PIPE_BUFFER);
252
253            let worker = tokio::task::spawn_blocking(move || -> Result<()> {
254                let mut source = OstreeDataSource::new(repo, &source_commit)?;
255                let mut dst = BufWriter::with_capacity(PIPE_BUFFER, SyncIoBridge::new(writer));
256                reconstruct_layer_to(blob, &media_type, &mut source, &diff_id, &mut dst)?;
257                dst.into_inner()
258                    .map_err(|e| anyhow!("Flushing reconstructed layer: {e}"))?
259                    .shutdown()?;
260                Ok(())
261            });
262            let driver = async move { worker.await.context("Delta worker")? };
263
264            Ok((
265                Box::new(tokio::io::BufReader::new(reader)) as Box<dyn AsyncBufRead + Send + Unpin>,
266                Box::pin(driver) as BoxFuture<'a, _>,
267                oci_image::MediaType::ImageLayer,
268            ))
269        })
270    }
271
272    fn finish(self: Box<Self>) -> BoxFuture<'static, Result<()>> {
273        Box::pin(std::future::ready(Ok(())))
274    }
275}
276
277/// Find the ostree commit holding the image this delta was built against.
278///
279/// Deltas are applied offline, so an absent source is fatal rather than a
280/// reason to go to the registry.
281#[context("Finding delta source image")]
282pub(crate) fn find_source_commit(repo: &ostree::Repo, delta: &Delta) -> Result<String> {
283    let wanted = delta.source_config_digest();
284    for image in ostree_container::store::list_images(repo)? {
285        let imgref = ostree_container::ImageReference::try_from(image.as_str())
286            .with_context(|| format!("Parsing stored image reference {image}"))?;
287        let Some(state) = ostree_container::store::query_image(repo, &imgref)? else {
288            continue;
289        };
290        if state.manifest.config().digest() == wanted {
291            tracing::debug!("Delta source {wanted} is {imgref} ({})", state.merge_commit);
292            return Ok(state.merge_commit);
293        }
294    }
295    for commit in
296        ostree_container::store::list_container_deployment_commits(repo, gio::Cancellable::NONE)?
297    {
298        let state = ostree_container::store::query_image_commit(repo, &commit)?;
299        if state.manifest.config().digest() == wanted {
300            return Ok(commit);
301        }
302    }
303    bail!(
304        "Delta {} was built against the image with config {wanted}, which is not present in this \
305         system's ostree repository. A delta can only be applied on top of its source image.",
306        delta.path,
307    )
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use anyhow::ensure;
314    use camino::{Utf8Path, Utf8PathBuf};
315    use cap_std_ext::cap_std::ambient_authority;
316    use oci_delta::MEDIA_TYPE_DELTA;
317    use ocidir::OciDir;
318    use ocidir::prelude::*;
319    use ostree_ext::fixture::{FileDef, Fixture};
320    use std::collections::HashSet;
321    use std::os::fd::{AsFd, AsRawFd};
322    use std::process::{Command, Stdio};
323
324    #[test]
325    fn test_source_path_candidates() {
326        assert_eq!(source_path_candidates("usr/bin/ls"), ["usr/bin/ls"]);
327        assert_eq!(
328            source_path_candidates("etc/passwd"),
329            ["etc/passwd", "usr/etc/passwd"]
330        );
331        assert_eq!(
332            source_path_candidates("var/lib/foo"),
333            ["var/lib/foo", "usr/share/factory/var/lib/foo"]
334        );
335        // Only the first component is remapped.
336        assert_eq!(
337            source_path_candidates("usr/share/etc/x"),
338            ["usr/share/etc/x"]
339        );
340    }
341
342    /// Commit `dir` as `testref` and return an [`OstreeDataSource`] for it.
343    fn data_source_for(repo: &ostree::Repo, dir: &Dir) -> Result<OstreeDataSource> {
344        let cancellable = gio::Cancellable::NONE;
345        let txn = repo.auto_transaction(cancellable)?;
346        let mt = ostree::MutableTree::new();
347        let modifier =
348            ostree::RepoCommitModifier::new(ostree::RepoCommitModifierFlags::SKIP_XATTRS, None);
349        repo.write_dfd_to_mtree(
350            dir.as_fd().as_raw_fd(),
351            ".",
352            &mt,
353            Some(&modifier),
354            cancellable,
355        )?;
356        let root = repo.write_mtree(&mt, cancellable)?;
357        let root = root.downcast::<ostree::RepoFile>().unwrap();
358        let commit = repo.write_commit(None, None, None, None, &root, cancellable)?;
359        txn.commit(cancellable)?;
360
361        OstreeDataSource::new(repo.clone(), commit.as_str())
362    }
363
364    fn read_all(source: &mut OstreeDataSource, path: &str) -> Result<Vec<u8>> {
365        source.set_current_file(path)?;
366        let mut out = Vec::new();
367        source.current()?.read_to_end(&mut out)?;
368        Ok(out)
369    }
370
371    #[test]
372    fn test_ostree_data_source() -> Result<()> {
373        let td = cap_std_ext::cap_tempfile::TempDir::new(ambient_authority())?;
374        td.create_dir("repo")?;
375        let repo = ostree::Repo::create_at(
376            td.as_fd().as_raw_fd(),
377            "repo",
378            ostree::RepoMode::BareUser,
379            None,
380            gio::Cancellable::NONE,
381        )?;
382
383        td.create_dir_all("rootfs/usr/bin")?;
384        td.create_dir_all("rootfs/usr/etc")?;
385        td.create_dir_all("rootfs/usr/share/factory/var/lib")?;
386        td.write("rootfs/usr/bin/ls", b"binary")?;
387        td.write("rootfs/usr/etc/passwd", b"root:x:0:0")?;
388        td.write("rootfs/usr/share/factory/var/lib/state", b"stateful")?;
389        let rootfs = td.open_dir("rootfs")?;
390
391        let mut source = data_source_for(&repo, &rootfs)?;
392
393        assert_eq!(read_all(&mut source, "usr/bin/ls")?, b"binary");
394        // The remapped locations are found under their pre-import paths.
395        assert_eq!(read_all(&mut source, "etc/passwd")?, b"root:x:0:0");
396        assert_eq!(read_all(&mut source, "var/lib/state")?, b"stateful");
397        // As are leading-`./` forms, which is how tar names entries.
398        assert_eq!(read_all(&mut source, "./etc/passwd")?, b"root:x:0:0");
399
400        // Partial reads from an offset, which is what a tar-diff mostly does.
401        source.set_current_file("usr/etc/passwd")?;
402        source.seek_current(5)?;
403        let mut buf = [0u8; 3];
404        source.read_exact_current(&mut buf)?;
405        assert_eq!(&buf, b"x:0");
406
407        // A directory is not content, and neither is an absent path.
408        for missing in ["usr/bin", "usr/bin/nope", "etc/nope"] {
409            let err = source.set_current_file(missing).unwrap_err();
410            assert!(
411                format!("{err:#}").contains("not found in source commit"),
412                "{missing}: unexpected error: {err:#}"
413            );
414        }
415
416        // A short read is an error rather than a silent truncation.
417        source.set_current_file("usr/bin/ls")?;
418        let err = source.copy_to(&mut Vec::new(), 100).unwrap_err();
419        assert!(format!("{err:#}").contains("Short read"), "{err:#}");
420
421        Ok(())
422    }
423
424    /// A delta whose source image is not in the repository must fail, not fall
425    /// back to fetching it: there may well be no network at this point.
426    #[tokio::test]
427    async fn test_find_source_commit_absent() -> Result<()> {
428        let t = crate::delta::tests::TestDelta::new();
429        t.finish_default();
430        let delta = crate::delta::Delta::open(t.path()).await?;
431
432        let td = cap_std_ext::cap_tempfile::TempDir::new(ambient_authority())?;
433        td.create_dir("repo")?;
434        let repo = ostree::Repo::create_at(
435            td.as_fd().as_raw_fd(),
436            "repo",
437            ostree::RepoMode::BareUser,
438            None,
439            gio::Cancellable::NONE,
440        )?;
441
442        let err = find_source_commit(&repo, &delta).unwrap_err();
443        assert!(
444            format!("{err:#}").contains("not present in this system's ostree repository"),
445            "{err:#}"
446        );
447        Ok(())
448    }
449
450    fn have_tool(name: &str) -> bool {
451        Command::new(name)
452            .arg("--help")
453            .stdout(Stdio::null())
454            .stderr(Stdio::null())
455            .status()
456            .is_ok()
457    }
458
459    fn copy_dir(src: &Utf8Path, dst: &Utf8Path) -> Result<()> {
460        let st = Command::new("cp")
461            .args(["-a", src.as_str(), dst.as_str()])
462            .status()?;
463        ensure!(st.success(), "cp -a {src} {dst} failed: {st}");
464        Ok(())
465    }
466
467    fn open_oci(path: &Utf8Path) -> Result<OciDir> {
468        Ok(OciDir::open(Dir::open_ambient_dir(
469            path,
470            ambient_authority(),
471        )?)?)
472    }
473
474    fn single_manifest(oci: &OciDir) -> Result<(oci_image::Descriptor, oci_image::ImageManifest)> {
475        let index = oci.read_index()?;
476        let desc = index
477            .manifests()
478            .first()
479            .cloned()
480            .context("Layout has no manifest")?;
481        let manifest = oci.read_json_blob(&desc)?;
482        Ok((desc, manifest))
483    }
484
485    fn read_blob(oci: &OciDir, desc: &oci_image::Descriptor) -> Result<Vec<u8>> {
486        let mut buf = Vec::new();
487        oci.read_blob(desc)?.read_to_end(&mut buf)?;
488        Ok(buf)
489    }
490
491    /// Import `path` as the image a delta will be applied on top of.
492    async fn import_source(fixture: &Fixture, path: &Utf8Path) -> Result<()> {
493        fixture
494            .must_import(&ostree_container::ImageReference {
495                transport: ostree_container::Transport::OciDir,
496                name: path.to_string(),
497            })
498            .await?;
499        Ok(())
500    }
501
502    /// Apply the delta at `delta_path` as an update to `target_path`.
503    async fn apply_delta(
504        fixture: &Fixture,
505        target_path: &Utf8Path,
506        delta_path: &Utf8Path,
507    ) -> Result<(Arc<crate::delta::Delta>, Box<crate::deploy::ImageState>)> {
508        let delta = Arc::new(crate::delta::Delta::open(delta_path).await?);
509        let imgref = crate::spec::ImageReference {
510            image: target_path.to_string(),
511            transport: "oci".into(),
512            signature: None,
513        };
514        let state = crate::deploy::pull_delta(
515            fixture.destrepo(),
516            &imgref,
517            Arc::clone(&delta),
518            true,
519            Default::default(),
520            None,
521        )
522        .await?;
523        Ok((delta, state))
524    }
525
526    /// The delta reconstructed the target image exactly: the same content as
527    /// the fixture's own commit, recorded under the target's real digest.
528    fn assert_applied(
529        fixture: &Fixture,
530        state: &crate::deploy::ImageState,
531        target: &oci_image::Digest,
532    ) {
533        assert_eq!(&state.manifest_digest, target);
534        let expected = fixture.srcrepo().require_rev(fixture.testref()).unwrap();
535        let layered =
536            ostree_container::store::query_image_commit(fixture.destrepo(), &state.ostree_commit)
537                .unwrap();
538        ostree_ext::fixture::assert_commits_content_equal(
539            fixture.destrepo(),
540            &layered.base_commit,
541            fixture.srcrepo(),
542            &expected,
543        );
544    }
545
546    /// Export the fixture as a container, change it, and export it again: a
547    /// pair of ostree-native (chunked) images, as a bootc base image update
548    /// looks. The source is imported into the destination repo.
549    async fn chunked_source_and_target()
550    -> Result<(Fixture, Utf8PathBuf, Utf8PathBuf, oci_image::Digest)> {
551        let mut fixture = Fixture::new_v1()?;
552        let (exported, _) = fixture.export_container().await?;
553        let source = fixture.path.join("source-oci");
554        copy_dir(Utf8Path::new(&exported.name), &source)?;
555
556        fixture.update(
557            // Both of these are paths the fixture has an owning "package" for,
558            // which its chunked export requires.
559            FileDef::iter_from(
560                "r usr/bin/bash the-bash-shell-v2\nr usr/etc/someconfig.conf someconfig-v2\n",
561            ),
562            std::iter::empty(),
563        )?;
564        let (exported, digest) = fixture.export_container().await?;
565        let target = Utf8PathBuf::from(exported.name);
566
567        import_source(&fixture, &source).await?;
568        Ok((fixture, source, target, digest))
569    }
570
571    /// 64 KiB of incompressible but deterministic data. `v2` differs from `v1`
572    /// in eight bytes, so a binary diff of the two is tiny - which is how the
573    /// tests tell whether the patch really read from the source.
574    fn big_file(v2: bool) -> Vec<u8> {
575        let mut state = 0x1234_5678u32;
576        let mut data: Vec<u8> = std::iter::repeat_with(|| {
577            state = state.wrapping_mul(1664525).wrapping_add(1013904223);
578            (state >> 24) as u8
579        })
580        .take(64 * 1024)
581        .collect();
582        if v2 {
583            data[32 * 1024..32 * 1024 + 8].fill(0xff);
584        }
585        data
586    }
587
588    /// Append a derived layer holding [`big_file`] at `/etc/bigconf`, i.e. at a
589    /// path the ostree importer relocates.
590    fn derive(oci: &Utf8Path, v2: bool) -> Result<()> {
591        let content = big_file(v2);
592        ostree_ext::integrationtest::generate_derived_oci_from_tar(
593            oci,
594            move |w| {
595                let mut tar = tar::Builder::new(w);
596                let mut dir = tar::Header::new_gnu();
597                dir.set_entry_type(tar::EntryType::Directory);
598                dir.set_mode(0o755);
599                dir.set_size(0);
600                tar.append_data(&mut dir, "etc/", std::io::empty())?;
601                let mut file = tar::Header::new_gnu();
602                file.set_mode(0o644);
603                file.set_size(content.len() as u64);
604                tar.append_data(&mut file, "etc/bigconf", content.as_slice())?;
605                tar.finish()?;
606                Ok(())
607            },
608            None,
609            None,
610        )
611    }
612
613    /// A pair of images that share a base and differ only in one derived layer,
614    /// as a bootc image built from a Containerfile does. The source is imported
615    /// into the destination repo.
616    async fn derived_source_and_target()
617    -> Result<(Fixture, Utf8PathBuf, Utf8PathBuf, oci_image::Digest)> {
618        let fixture = Fixture::new_v1()?;
619        let (exported, _) = fixture.export_container().await?;
620        let source = fixture.path.join("source-oci");
621        let target = fixture.path.join("target-oci");
622        copy_dir(Utf8Path::new(&exported.name), &source)?;
623        copy_dir(Utf8Path::new(&exported.name), &target)?;
624        derive(&source, false)?;
625        derive(&target, true)?;
626        let digest = single_manifest(&open_oci(&target)?)?.0.digest().clone();
627
628        import_source(&fixture, &source).await?;
629        Ok((fixture, source, target, digest))
630    }
631
632    /// Write a delta from `source` to `target` that carries each changed layer
633    /// whole rather than as a tar-diff.
634    ///
635    /// The format permits either, so this covers everything but the patch
636    /// application itself while needing no external tooling; the tests below
637    /// that use the real `oci-delta` skip themselves when it is absent.
638    fn build_whole_layer_delta(source: &Utf8Path, target: &Utf8Path, out: &Utf8Path) -> Result<()> {
639        use crate::delta::tests::{DELTA_CONTENT, DELTA_SOURCE_CONFIG, DELTA_TO, annotate, blob};
640
641        let source = open_oci(source)?;
642        let target = open_oci(target)?;
643        let (_, source_manifest) = single_manifest(&source)?;
644        let (target_manifest_desc, target_manifest) = single_manifest(&target)?;
645
646        std::fs::create_dir_all(out)?;
647        let out = OciDir::ensure(Dir::open_ambient_dir(out, ambient_authority())?)?;
648
649        let mut layers = vec![
650            annotate(
651                blob(
652                    &out,
653                    &read_blob(&target, &target_manifest_desc)?,
654                    oci_image::MediaType::ImageManifest,
655                ),
656                &[(DELTA_CONTENT, "image-manifest")],
657            ),
658            annotate(
659                blob(
660                    &out,
661                    &read_blob(&target, target_manifest.config())?,
662                    oci_image::MediaType::ImageConfig,
663                ),
664                &[(DELTA_CONTENT, "image-config")],
665            ),
666        ];
667        let shared: HashSet<_> = source_manifest
668            .layers()
669            .iter()
670            .map(|l| l.digest())
671            .collect();
672        for layer in target_manifest.layers() {
673            if shared.contains(layer.digest()) {
674                continue;
675            }
676            let patch = blob(
677                &out,
678                &read_blob(&target, layer)?,
679                layer.media_type().clone(),
680            );
681            let to = layer.digest().to_string();
682            layers.push(annotate(
683                patch,
684                &[(DELTA_CONTENT, "image-layer"), (DELTA_TO, to.as_str())],
685            ));
686        }
687        ensure!(
688            layers.len() > 2,
689            "Source and target images share every layer"
690        );
691
692        let empty = blob(&out, b"{}", oci_image::MediaType::EmptyJSON);
693        let manifest = oci_image::ImageManifestBuilder::default()
694            .schema_version(2u32)
695            .media_type(oci_image::MediaType::ImageManifest)
696            .artifact_type(oci_image::MediaType::Other(MEDIA_TYPE_DELTA.to_string()))
697            .config(empty)
698            .layers(layers)
699            .annotations(std::collections::HashMap::from([(
700                DELTA_SOURCE_CONFIG.to_string(),
701                source_manifest.config().digest().to_string(),
702            )]))
703            .build()?;
704        out.replace_with_single_manifest(manifest, Default::default())?;
705        Ok(())
706    }
707
708    fn create_delta(source: &Utf8Path, target: &Utf8Path, out: &Utf8Path) -> Result<()> {
709        let out = Command::new("oci-delta")
710            .arg("create")
711            .arg(format!("oci:{source}"))
712            .arg(format!("oci:{target}"))
713            .arg(format!("oci:{out}"))
714            .output()?;
715        ensure!(
716            out.status.success(),
717            "oci-delta create failed: {}\n{}",
718            out.status,
719            String::from_utf8_lossy(&out.stderr),
720        );
721        Ok(())
722    }
723
724    /// The whole pipeline - parse and validate the delta, find the source image
725    /// in the repository, reconstruct the changed layers, import - with the
726    /// patches degenerate so that no external tooling is needed.
727    #[tokio::test]
728    async fn test_apply_delta_whole_layers() -> Result<()> {
729        let (fixture, source, target, digest) = chunked_source_and_target().await?;
730        let delta_path = fixture.path.join("delta");
731        build_whole_layer_delta(&source, &target, &delta_path)?;
732
733        let (_, state) = apply_delta(&fixture, &target, &delta_path).await?;
734        assert_applied(&fixture, &state, &digest);
735        Ok(())
736    }
737
738    /// Construct and apply a real delta between two ostree-native (chunked) images.
739    #[tokio::test]
740    async fn test_apply_delta_chunked() -> Result<()> {
741        if !have_tool("oci-delta") {
742            eprintln!("skipping: oci-delta not found in PATH");
743            return Ok(());
744        }
745        let (fixture, source, target, digest) = chunked_source_and_target().await?;
746        let delta_path = fixture.path.join("delta");
747        create_delta(&source, &target, &delta_path)?;
748
749        let (_, state) = apply_delta(&fixture, &target, &delta_path).await?;
750        assert_applied(&fixture, &state, &digest);
751        Ok(())
752    }
753
754    /// A real delta over a derived layer, which unlike a chunked one is an
755    /// ordinary root filesystem tar. This is the case that actually drives
756    /// [`OstreeDataSource`], and the file it patches is one the importer
757    /// relocated from `/etc` to `/usr/etc`.
758    #[tokio::test]
759    async fn test_apply_delta_derived() -> Result<()> {
760        if !have_tool("oci-delta") {
761            eprintln!("skipping: oci-delta not found in PATH");
762            return Ok(());
763        }
764        let (fixture, source, target, digest) = derived_source_and_target().await?;
765        let delta_path = fixture.path.join("delta");
766        create_delta(&source, &target, &delta_path)?;
767
768        let (delta, state) = apply_delta(&fixture, &target, &delta_path).await?;
769        assert_eq!(&state.manifest_digest, &digest);
770
771        // Only the derived layer changed, and the patch for it is a fraction of
772        // its size - which it can only be if the reconstruction read the bulk
773        // of the content back out of the source commit.
774        let patched = delta.parsed.delta_layer_by_to.iter().collect::<Vec<_>>();
775        let [(to, patch)] = patched.as_slice() else {
776            panic!("expected one patched layer, got {}", patched.len());
777        };
778        let layer = delta
779            .target_manifest()
780            .layers()
781            .iter()
782            .find(|l| l.digest() == *to)
783            .unwrap();
784        assert!(
785            patch.size() * 4 < layer.size(),
786            "patch is {} bytes against a {} byte layer, so nothing was reused from the source",
787            patch.size(),
788            layer.size(),
789        );
790
791        let root = ostree_ext::fixture::ostree_ls(fixture.destrepo(), &state.ostree_commit)?;
792        assert!(
793            root.contains(&format!("r /usr/etc/bigconf {}\n", 64 * 1024)),
794            "/usr/etc/bigconf is missing from the applied image:\n{root}"
795        );
796        Ok(())
797    }
798
799    /// A deployed source remains usable after staging advances to its image ref.
800    #[tokio::test]
801    async fn test_delta_source_retained_by_deployment() -> Result<()> {
802        let (mut fixture, source, target, _) = chunked_source_and_target().await?;
803        let repo = fixture.destrepo().clone();
804        let source_ref = ostree_container::ImageReference {
805            transport: ostree_container::Transport::OciDir,
806            name: source.to_string(),
807        };
808        let original = ostree_container::store::query_image(&repo, &source_ref)?.unwrap();
809        let newer = fixture
810            .must_import(&ostree_container::ImageReference {
811                transport: ostree_container::Transport::OciDir,
812                name: target.to_string(),
813            })
814            .await?;
815        // Model staging B over A under the same image reference.
816        for (name, commit) in repo.list_refs_ext(
817            Some("ostree/container/image"),
818            ostree::RepoListRefsExtFlags::empty(),
819            gio::Cancellable::NONE,
820        )? {
821            if commit.as_str() == original.merge_commit {
822                repo.set_ref_immediate(
823                    None,
824                    &name,
825                    Some(&newer.merge_commit),
826                    gio::Cancellable::NONE,
827                )?;
828            }
829        }
830
831        // Build A→C; no image ref now identifies A.
832        fixture.update(
833            FileDef::iter_from("r usr/bin/bash the-bash-shell-v3\n"),
834            std::iter::empty(),
835        )?;
836        let (_, digest) = fixture.export_container().await?;
837        let delta_path = fixture.path.join("delta-retained-source");
838        build_whole_layer_delta(&source, &target, &delta_path)?;
839        let delta = Delta::open(&delta_path).await?;
840        assert!(find_source_commit(&repo, &delta).is_err());
841
842        // Ordinary ostree deployments have no container metadata.
843        repo.set_ref_immediate(
844            None,
845            "ostree/0/0/1",
846            Some(&original.base_commit),
847            gio::Cancellable::NONE,
848        )?;
849        // Each supported deployment/base-image ref can retain A independently.
850        for name in [
851            "ostree/0/0/0",
852            "ostree/1/0/0",
853            "rpmostree/base/test",
854            "ostree/container/baseimage/test",
855        ] {
856            repo.set_ref_immediate(
857                None,
858                name,
859                Some(&original.merge_commit),
860                gio::Cancellable::NONE,
861            )?;
862            assert_eq!(find_source_commit(&repo, &delta)?, original.merge_commit);
863            repo.set_ref_immediate(None, name, None, gio::Cancellable::NONE)?;
864        }
865        repo.set_ref_immediate(
866            None,
867            "ostree/0/0/0",
868            Some(&original.merge_commit),
869            gio::Cancellable::NONE,
870        )?;
871        // Apply A→C using A retained solely by its deployment ref.
872        let (_, state) = apply_delta(&fixture, &target, &delta_path).await?;
873        assert_applied(&fixture, &state, &digest);
874        Ok(())
875    }
876}