1use 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
43const PIPE_BUFFER: usize = 128 * 1024;
46
47fn 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
70struct 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#[derive(Debug)]
166pub(crate) struct DeltaLayerSource {
167 delta: Arc<Delta>,
168 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 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 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 _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#[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 assert_eq!(
337 source_path_candidates("usr/share/etc/x"),
338 ["usr/share/etc/x"]
339 );
340 }
341
342 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 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 assert_eq!(read_all(&mut source, "./etc/passwd")?, b"root:x:0:0");
399
400 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 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 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 #[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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 #[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 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 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 repo.set_ref_immediate(
844 None,
845 "ostree/0/0/1",
846 Some(&original.base_commit),
847 gio::Cancellable::NONE,
848 )?;
849 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 let (_, state) = apply_delta(&fixture, &target, &delta_path).await?;
873 assert_applied(&fixture, &state, &digest);
874 Ok(())
875 }
876}