diff --git a/src/diff.rs b/src/diff.rs new file mode 100644 index 00000000..d5c3ac62 --- /dev/null +++ b/src/diff.rs @@ -0,0 +1,182 @@ +//! Compute the difference between two OSTree commits. + +/* + * Copyright (C) 2020 Red Hat, Inc. + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use anyhow::{Context, Result}; +use fn_error_context::context; +use gio::prelude::*; +use ostree::RepoFileExt; +use std::collections::BTreeSet; +use std::fmt; + +/// Like `g_file_query_info()`, but return None if the target doesn't exist. +fn query_info_optional( + f: &gio::File, + queryattrs: &str, + queryflags: gio::FileQueryInfoFlags, +) -> Result> { + let cancellable = gio::NONE_CANCELLABLE; + match f.query_info(queryattrs, queryflags, cancellable) { + Ok(i) => Ok(Some(i)), + Err(e) => { + if let Some(ref e2) = e.kind::() { + match e2 { + gio::IOErrorEnum::NotFound => Ok(None), + _ => Err(e.into()), + } + } else { + Err(e.into()) + } + } + } +} + +/// A set of file paths. +pub type FileSet = BTreeSet; + +/// Diff between two ostree commits. +#[derive(Debug, Default)] +pub struct FileTreeDiff { + /// The prefix passed for diffing, e.g. /usr + pub subdir: Option, + /// Files that are new in an existing directory + pub added_files: FileSet, + /// New directories + pub added_dirs: FileSet, + /// Files removed + pub removed_files: FileSet, + /// Directories removed (recursively) + pub removed_dirs: FileSet, + /// Files that changed (in any way, metadata or content) + pub changed_files: FileSet, + /// Directories that changed mode/permissions + pub changed_dirs: FileSet, +} + +impl fmt::Display for FileTreeDiff { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "files(added:{} removed:{} changed:{}) dirs(added:{} removed:{} changed:{})", + self.added_files.len(), + self.removed_files.len(), + self.changed_files.len(), + self.added_dirs.len(), + self.removed_dirs.len(), + self.changed_dirs.len() + ) + } +} + +fn diff_recurse( + prefix: &str, + diff: &mut FileTreeDiff, + from: &ostree::RepoFile, + to: &ostree::RepoFile, +) -> Result<()> { + let cancellable = gio::NONE_CANCELLABLE; + let queryattrs = "standard::name,standard::type"; + let queryflags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS; + let from_iter = from.enumerate_children(queryattrs, queryflags, cancellable)?; + + // Iterate over the source (from) directory, and compare with the + // target (to) directory. This generates removals and changes. + while let Some(from_info) = from_iter.next_file(cancellable)? { + let from_child = from_iter.get_child(&from_info).expect("file"); + let name = from_info.get_name().expect("name"); + let name = name.to_str().expect("UTF-8 ostree name"); + let path = format!("{}{}", prefix, name); + let to_child = to.get_child(&name).expect("child"); + let to_info = query_info_optional(&to_child, queryattrs, queryflags) + .context("querying optional to")?; + let is_dir = matches!(from_info.get_file_type(), gio::FileType::Directory); + if to_info.is_some() { + let to_child = to_child.downcast::().expect("downcast"); + to_child.ensure_resolved()?; + let from_child = from_child.downcast::().expect("downcast"); + from_child.ensure_resolved()?; + + if is_dir { + let from_contents_checksum = + from_child.tree_get_contents_checksum().expect("checksum"); + let to_contents_checksum = to_child.tree_get_contents_checksum().expect("checksum"); + if from_contents_checksum != to_contents_checksum { + let subpath = format!("{}/", path); + diff_recurse(&subpath, diff, &from_child, &to_child)?; + } + let from_meta_checksum = from_child.tree_get_metadata_checksum().expect("checksum"); + let to_meta_checksum = to_child.tree_get_metadata_checksum().expect("checksum"); + if from_meta_checksum != to_meta_checksum { + diff.changed_dirs.insert(path); + } + } else { + let from_checksum = from_child.get_checksum().expect("checksum"); + let to_checksum = to_child.get_checksum().expect("checksum"); + if from_checksum != to_checksum { + diff.changed_files.insert(path); + } + } + } else if is_dir { + diff.removed_dirs.insert(path); + } else { + diff.removed_files.insert(path); + } + } + // Iterate over the target (to) directory, and find any + // files/directories which were not present in the source. + let to_iter = to.enumerate_children(queryattrs, queryflags, cancellable)?; + while let Some(to_info) = to_iter.next_file(cancellable)? { + let name = to_info.get_name().expect("name"); + let name = name.to_str().expect("UTF-8 ostree name"); + let path = format!("{}{}", prefix, name); + let from_child = from.get_child(name).expect("child"); + let from_info = query_info_optional(&from_child, queryattrs, queryflags) + .context("querying optional from")?; + if from_info.is_some() { + continue; + } + let is_dir = matches!(to_info.get_file_type(), gio::FileType::Directory); + if is_dir { + diff.added_dirs.insert(path); + } else { + diff.added_files.insert(path); + } + } + Ok(()) +} + +/// Given two ostree commits, compute the diff between them. +#[context("Computing ostree diff")] +pub fn diff>( + repo: &ostree::Repo, + from: &str, + to: &str, + subdir: Option

, +) -> Result { + let subdir = subdir.as_ref(); + let subdir = subdir.map(|s| s.as_ref()); + let (fromroot, _) = repo.read_commit(from, gio::NONE_CANCELLABLE)?; + let (toroot, _) = repo.read_commit(to, gio::NONE_CANCELLABLE)?; + let (fromroot, toroot) = if let Some(subdir) = subdir { + ( + fromroot.resolve_relative_path(subdir).expect("path"), + toroot.resolve_relative_path(subdir).expect("path"), + ) + } else { + (fromroot, toroot) + }; + let fromroot = fromroot.downcast::().expect("downcast"); + fromroot.ensure_resolved()?; + let toroot = toroot.downcast::().expect("downcast"); + toroot.ensure_resolved()?; + let mut diff = FileTreeDiff { + subdir: subdir.map(|s| s.to_string()), + ..Default::default() + }; + diff_recurse("/", &mut diff, &fromroot, &toroot)?; + Ok(diff) +} diff --git a/tests/it/fixtures/exampleos-v1.tar.zst b/tests/it/fixtures/exampleos-v1.tar.zst new file mode 100644 index 00000000..de20d2dc Binary files /dev/null and b/tests/it/fixtures/exampleos-v1.tar.zst differ diff --git a/tests/it/main.rs b/tests/it/main.rs index 203c8df3..8651e8f4 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -5,28 +5,48 @@ use indoc::indoc; use sh_inline::bash; use std::io::Write; -const EXAMPLEOS_TAR: &[u8] = include_bytes!("fixtures/exampleos.tar.zst"); +const EXAMPLEOS_V0: &[u8] = include_bytes!("fixtures/exampleos.tar.zst"); +const EXAMPLEOS_V1: &[u8] = include_bytes!("fixtures/exampleos-v1.tar.zst"); const TESTREF: &str = "exampleos/x86_64/stable"; -const CONTENT_CHECKSUM: &str = "0ef7461f9db15e1d8bd8921abf20694225fbaa4462cadf7deed8ea0e43162120"; +const EXAMPLEOS_CONTENT_CHECKSUM: &str = + "0ef7461f9db15e1d8bd8921abf20694225fbaa4462cadf7deed8ea0e43162120"; fn generate_test_repo(dir: &Utf8Path) -> Result { let src_tarpath = &dir.join("exampleos.tar.zst"); - std::fs::write(src_tarpath, EXAMPLEOS_TAR)?; + std::fs::write(src_tarpath, EXAMPLEOS_V0)?; + bash!( indoc! {" - cd {path} - ostree --repo=repo-archive init --mode=archive - ostree --repo=repo-archive commit -b {testref} --tree=tar=exampleos.tar.zst - ostree --repo=repo-archive show {testref} + cd {dir} + ostree --repo=repo init --mode=archive + ostree --repo=repo commit -b {testref} --tree=tar=exampleos.tar.zst + ostree --repo=repo show {testref} "}, testref = TESTREF, - path = path.as_str() + dir = dir.as_str() )?; std::fs::remove_file(src_tarpath)?; Ok(dir.join("repo")) } -#[context("Generating test OCI")] +fn update_repo(repopath: &Utf8Path) -> Result<()> { + let repotmp = &repopath.join("tmp"); + let srcpath = &repotmp.join("exampleos-v1.tar.zst"); + std::fs::write(srcpath, EXAMPLEOS_V1)?; + let srcpath = srcpath.as_str(); + let repopath = repopath.as_str(); + let testref = TESTREF; + bash!( + "ostree --repo={repopath} commit -b {testref} --tree=tar={srcpath}", + testref, + repopath, + srcpath + )?; + std::fs::remove_file(srcpath)?; + Ok(()) +} + +#[context("Generating test tarball")] fn generate_test_tarball(dir: &Utf8Path) -> Result { let cancellable = gio::NONE_CANCELLABLE; let repopath = generate_test_repo(dir)?; @@ -37,9 +57,9 @@ fn generate_test_tarball(dir: &Utf8Path) -> Result { ostree::commit_get_content_checksum(&commitv) .unwrap() .as_str(), - CONTENT_CHECKSUM + EXAMPLEOS_CONTENT_CHECKSUM ); - let destpath = path.join("exampleos-export.tar"); + let destpath = dir.join("exampleos-export.tar"); let mut outf = std::io::BufWriter::new(std::fs::File::create(&destpath)?); ostree_ext::tar::export_commit(repo, rev.as_str(), &mut outf)?; outf.flush()?; @@ -47,7 +67,7 @@ fn generate_test_tarball(dir: &Utf8Path) -> Result { } #[test] -fn test_e2e() -> Result<()> { +fn test_tar_import_export() -> Result<()> { let cancellable = gio::NONE_CANCELLABLE; let tempdir = tempfile::tempdir()?; @@ -65,7 +85,7 @@ fn test_e2e() -> Result<()> { let imported_commit: String = ostree_ext::tar::import_tar(&destrepo, src_tar)?; let (commitdata, _) = destrepo.load_commit(&imported_commit)?; assert_eq!( - CONTENT_CHECKSUM, + EXAMPLEOS_CONTENT_CHECKSUM, ostree::commit_get_content_checksum(&commitdata) .unwrap() .as_str() @@ -77,3 +97,32 @@ fn test_e2e() -> Result<()> { )?; Ok(()) } + +#[test] +fn test_diff() -> Result<()> { + let cancellable = gio::NONE_CANCELLABLE; + let tempdir = tempfile::tempdir()?; + let tempdir = Utf8Path::from_path(tempdir.path()).unwrap(); + let repopath = &generate_test_repo(tempdir)?; + update_repo(repopath)?; + let from = &format!("{}^", TESTREF); + let repo = &ostree::Repo::open_at(libc::AT_FDCWD, repopath.as_str(), cancellable)?; + let subdir: Option<&str> = None; + let diff = ostree_ext::diff::diff(repo, from, TESTREF, subdir)?; + assert!(diff.subdir.is_none()); + assert_eq!(diff.added_dirs.len(), 1); + assert_eq!(diff.added_dirs.iter().nth(0).unwrap(), "/usr/share"); + assert_eq!(diff.added_files.len(), 1); + assert_eq!(diff.added_files.iter().nth(0).unwrap(), "/usr/bin/newbin"); + assert_eq!(diff.removed_files.len(), 1); + assert_eq!(diff.removed_files.iter().nth(0).unwrap(), "/usr/bin/foo"); + let diff = ostree_ext::diff::diff(repo, from, TESTREF, Some("/usr"))?; + assert_eq!(diff.subdir.as_ref().unwrap(), "/usr"); + assert_eq!(diff.added_dirs.len(), 1); + assert_eq!(diff.added_dirs.iter().nth(0).unwrap(), "/share"); + assert_eq!(diff.added_files.len(), 1); + assert_eq!(diff.added_files.iter().nth(0).unwrap(), "/bin/newbin"); + assert_eq!(diff.removed_files.len(), 1); + assert_eq!(diff.removed_files.iter().nth(0).unwrap(), "/bin/foo"); + Ok(()) +}