ostree_ext/
diff.rs

1//! Compute the difference between two OSTree commits.
2
3/*
4 * Copyright (C) 2020 Red Hat, Inc.
5 *
6 * SPDX-License-Identifier: Apache-2.0 OR MIT
7 */
8
9use anyhow::{Context, Result};
10use fn_error_context::context;
11use gio::prelude::*;
12use ostree::gio;
13use std::collections::BTreeSet;
14use std::fmt;
15
16/// Like `g_file_query_info()`, but return None if the target doesn't exist.
17pub(crate) fn query_info_optional(
18    f: &gio::File,
19    queryattrs: &str,
20    queryflags: gio::FileQueryInfoFlags,
21) -> Result<Option<gio::FileInfo>> {
22    let cancellable = gio::Cancellable::NONE;
23    match f.query_info(queryattrs, queryflags, cancellable) {
24        Ok(i) => Ok(Some(i)),
25        Err(e) => {
26            if let Some(ref e2) = e.kind::<gio::IOErrorEnum>() {
27                match e2 {
28                    gio::IOErrorEnum::NotFound => Ok(None),
29                    _ => Err(e.into()),
30                }
31            } else {
32                Err(e.into())
33            }
34        }
35    }
36}
37
38/// A set of file paths.
39pub type FileSet = BTreeSet<String>;
40
41/// Diff between two ostree commits.
42#[derive(Debug, Default)]
43pub struct FileTreeDiff {
44    /// The prefix passed for diffing, e.g. /usr
45    pub subdir: Option<String>,
46    /// Files that are new in an existing directory
47    pub added_files: FileSet,
48    /// New directories
49    pub added_dirs: FileSet,
50    /// Files removed
51    pub removed_files: FileSet,
52    /// Directories removed (recursively)
53    pub removed_dirs: FileSet,
54    /// Files that changed (in any way, metadata or content)
55    pub changed_files: FileSet,
56    /// Directories that changed mode/permissions
57    pub changed_dirs: FileSet,
58}
59
60impl fmt::Display for FileTreeDiff {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        write!(
63            f,
64            "files(added:{} removed:{} changed:{}) dirs(added:{} removed:{} changed:{})",
65            self.added_files.len(),
66            self.removed_files.len(),
67            self.changed_files.len(),
68            self.added_dirs.len(),
69            self.removed_dirs.len(),
70            self.changed_dirs.len()
71        )
72    }
73}
74
75fn diff_recurse(
76    prefix: &str,
77    diff: &mut FileTreeDiff,
78    from: &ostree::RepoFile,
79    to: &ostree::RepoFile,
80) -> Result<()> {
81    let cancellable = gio::Cancellable::NONE;
82    let queryattrs = "standard::name,standard::type";
83    let queryflags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS;
84    let from_iter = from.enumerate_children(queryattrs, queryflags, cancellable)?;
85
86    // Iterate over the source (from) directory, and compare with the
87    // target (to) directory.  This generates removals and changes.
88    while let Some(from_info) = from_iter.next_file(cancellable)? {
89        let from_child = from_iter.child(&from_info);
90        let name = from_info.name();
91        let name = name.to_str().expect("UTF-8 ostree name");
92        let path = format!("{prefix}{name}");
93        let to_child = to.child(name);
94        let to_info = query_info_optional(&to_child, queryattrs, queryflags)
95            .context("querying optional to")?;
96        let is_dir = matches!(from_info.file_type(), gio::FileType::Directory);
97        if to_info.is_some() {
98            let to_child = to_child.downcast::<ostree::RepoFile>().expect("downcast");
99            to_child.ensure_resolved()?;
100            let from_child = from_child.downcast::<ostree::RepoFile>().expect("downcast");
101            from_child.ensure_resolved()?;
102
103            if is_dir {
104                let from_contents_checksum = from_child.tree_get_contents_checksum();
105                let to_contents_checksum = to_child.tree_get_contents_checksum();
106                if from_contents_checksum != to_contents_checksum {
107                    let subpath = format!("{}/", path);
108                    diff_recurse(&subpath, diff, &from_child, &to_child)?;
109                }
110                let from_meta_checksum = from_child.tree_get_metadata_checksum();
111                let to_meta_checksum = to_child.tree_get_metadata_checksum();
112                if from_meta_checksum != to_meta_checksum {
113                    diff.changed_dirs.insert(path);
114                }
115            } else {
116                let from_checksum = from_child.checksum();
117                let to_checksum = to_child.checksum();
118                if from_checksum != to_checksum {
119                    diff.changed_files.insert(path);
120                }
121            }
122        } else if is_dir {
123            diff.removed_dirs.insert(path);
124        } else {
125            diff.removed_files.insert(path);
126        }
127    }
128    // Iterate over the target (to) directory, and find any
129    // files/directories which were not present in the source.
130    let to_iter = to.enumerate_children(queryattrs, queryflags, cancellable)?;
131    while let Some(to_info) = to_iter.next_file(cancellable)? {
132        let name = to_info.name();
133        let name = name.to_str().expect("UTF-8 ostree name");
134        let path = format!("{prefix}{name}");
135        let from_child = from.child(name);
136        let from_info = query_info_optional(&from_child, queryattrs, queryflags)
137            .context("querying optional from")?;
138        if from_info.is_some() {
139            continue;
140        }
141        let is_dir = matches!(to_info.file_type(), gio::FileType::Directory);
142        if is_dir {
143            diff.added_dirs.insert(path);
144        } else {
145            diff.added_files.insert(path);
146        }
147    }
148    Ok(())
149}
150
151/// Given two ostree commits, compute the diff between them.
152#[context("Computing ostree diff")]
153pub fn diff<P: AsRef<str>>(
154    repo: &ostree::Repo,
155    from: &str,
156    to: &str,
157    subdir: Option<P>,
158) -> Result<FileTreeDiff> {
159    let subdir = subdir.as_ref();
160    let subdir = subdir.map(|s| s.as_ref());
161    let (fromroot, _) = repo.read_commit(from, gio::Cancellable::NONE)?;
162    let (toroot, _) = repo.read_commit(to, gio::Cancellable::NONE)?;
163    let (fromroot, toroot) = if let Some(subdir) = subdir {
164        (
165            fromroot.resolve_relative_path(subdir),
166            toroot.resolve_relative_path(subdir),
167        )
168    } else {
169        (fromroot, toroot)
170    };
171    let fromroot = fromroot.downcast::<ostree::RepoFile>().expect("downcast");
172    fromroot.ensure_resolved()?;
173    let toroot = toroot.downcast::<ostree::RepoFile>().expect("downcast");
174    toroot.ensure_resolved()?;
175    let mut diff = FileTreeDiff {
176        subdir: subdir.map(|s| s.to_string()),
177        ..Default::default()
178    };
179    diff_recurse("/", &mut diff, &fromroot, &toroot)?;
180    Ok(diff)
181}