etc_merge/
lib.rs

1//! Lib for /etc merge
2
3#![allow(dead_code)]
4
5use fn_error_context::context;
6use std::cell::RefCell;
7use std::collections::BTreeMap;
8use std::ffi::OsStr;
9use std::io::BufReader;
10use std::io::Write;
11use std::os::fd::{AsFd, AsRawFd};
12use std::os::unix::ffi::OsStrExt;
13use std::path::{Path, PathBuf};
14use std::rc::Rc;
15
16use anyhow::Context;
17use cap_std_ext::cap_std;
18use cap_std_ext::cap_std::fs::{Dir as CapStdDir, MetadataExt, Permissions, PermissionsExt};
19use cap_std_ext::dirext::CapStdExtDirExt;
20use cfsctl::composefs;
21use composefs::fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue};
22use composefs::generic_tree::{Directory, Inode, Leaf, LeafContent, Stat};
23use composefs::tree::ImageError;
24use rustix::fs::{
25    AtFlags, Gid, Uid, XattrFlags, lgetxattr, llistxattr, lsetxattr, readlinkat, symlinkat,
26};
27
28/// Metadata associated with a file, directory, or symlink entry.
29#[derive(Debug)]
30pub struct CustomMetadata {
31    /// A SHA256 sum representing the file contents.
32    content_hash: String,
33    /// Optional verity for the file
34    verity: Option<String>,
35}
36
37impl CustomMetadata {
38    fn new(content_hash: String, verity: Option<String>) -> Self {
39        Self {
40            content_hash,
41            verity,
42        }
43    }
44}
45
46type Xattrs = RefCell<BTreeMap<Box<OsStr>, Box<[u8]>>>;
47
48struct MyStat(Stat);
49
50impl From<(&cap_std::fs::Metadata, Xattrs)> for MyStat {
51    fn from(value: (&cap_std::fs::Metadata, Xattrs)) -> Self {
52        Self(Stat {
53            st_mode: value.0.mode(),
54            st_uid: value.0.uid(),
55            st_gid: value.0.gid(),
56            st_mtim_sec: value.0.mtime(),
57            xattrs: value.1,
58        })
59    }
60}
61
62fn stat_eq_ignore_mtime(this: &Stat, other: &Stat) -> bool {
63    if this.st_uid != other.st_uid {
64        return false;
65    }
66
67    if this.st_gid != other.st_gid {
68        return false;
69    }
70
71    if this.st_mode != other.st_mode {
72        return false;
73    }
74
75    if this.xattrs != other.xattrs {
76        return false;
77    }
78
79    return true;
80}
81
82/// Represents the differences between two directory trees.
83#[derive(Debug)]
84pub struct Diff {
85    /// Paths that exist in the current /etc but not in the pristine
86    added: Vec<PathBuf>,
87    /// Paths that exist in both pristine and current /etc but differ in metadata
88    /// (e.g., file contents, permissions, symlink targets)
89    modified: Vec<PathBuf>,
90    /// Paths that exist in the pristine /etc but not in the current one
91    removed: Vec<PathBuf>,
92}
93
94fn collect_all_files(
95    root: &Directory<CustomMetadata>,
96    current_path: PathBuf,
97    files: &mut Vec<PathBuf>,
98) {
99    fn collect(
100        root: &Directory<CustomMetadata>,
101        mut current_path: PathBuf,
102        files: &mut Vec<PathBuf>,
103    ) {
104        for (path, inode) in root.sorted_entries() {
105            current_path.push(path);
106
107            files.push(current_path.clone());
108
109            if let Inode::Directory(dir) = inode {
110                collect(dir, current_path.clone(), files);
111            }
112
113            current_path.pop();
114        }
115    }
116
117    collect(root, current_path, files);
118}
119
120#[context("Getting deletions")]
121fn get_deletions(
122    pristine: &Directory<CustomMetadata>,
123    current: &Directory<CustomMetadata>,
124    mut current_path: PathBuf,
125    diff: &mut Diff,
126) -> anyhow::Result<()> {
127    for (file_name, inode) in pristine.sorted_entries() {
128        current_path.push(file_name);
129
130        match inode {
131            Inode::Directory(pristine_dir) => {
132                match current.get_directory(file_name) {
133                    Ok(curr_dir) => {
134                        get_deletions(pristine_dir, curr_dir, current_path.clone(), diff)?
135                    }
136
137                    Err(ImageError::NotFound(..)) => {
138                        // Directory was deleted
139                        diff.removed.push(current_path.clone());
140                    }
141
142                    Err(ImageError::NotADirectory(..)) => {
143                        // Already tracked in modifications
144                    }
145
146                    Err(e) => Err(e)?,
147                }
148            }
149
150            Inode::Leaf(..) => match current.ref_leaf(file_name) {
151                Ok(..) => {
152                    // Empty as all additions/modifications are tracked earlier in `get_modifications`
153                }
154
155                Err(ImageError::NotFound(..)) => {
156                    // File was deleted
157                    diff.removed.push(current_path.clone());
158                }
159
160                Err(ImageError::IsADirectory(..)) => {
161                    // Already tracked in modifications
162                }
163
164                Err(e) => Err(e).context(format!("{file_name:?}"))?,
165            },
166        }
167
168        current_path.pop();
169    }
170
171    Ok(())
172}
173
174// 1. Files in the currently booted deployment’s /etc which were modified from the default /usr/etc (of the same deployment) are retained.
175//
176// 2. Files in the currently booted deployment’s /etc which were not modified from the default /usr/etc (of the same deployment)
177// are upgraded to the new defaults from the new deployment’s /usr/etc.
178
179// Modifications
180// 1. File deleted from new /etc
181// 2. File added in new /etc
182//
183// 3. File modified in new /etc
184//    a. Content added/deleted
185//    b. Permissions/ownership changed
186//    c. Was a file but changed to directory/symlink etc or vice versa
187//    d. xattrs changed - we don't include this right now
188#[context("Getting modifications")]
189fn get_modifications(
190    pristine: &Directory<CustomMetadata>,
191    current: &Directory<CustomMetadata>,
192    new: &Directory<CustomMetadata>,
193    mut current_path: PathBuf,
194    diff: &mut Diff,
195) -> anyhow::Result<()> {
196    use composefs::generic_tree::LeafContent::*;
197
198    for (path, inode) in current.sorted_entries() {
199        current_path.push(path);
200
201        match inode {
202            Inode::Directory(curr_dir) => {
203                match pristine.get_directory(path) {
204                    Ok(old_dir) => {
205                        if !stat_eq_ignore_mtime(&curr_dir.stat, &old_dir.stat) {
206                            // Directory permissions/owner modified
207                            diff.modified.push(current_path.clone());
208                        }
209
210                        let total_added = diff.added.len();
211                        let total_modified = diff.modified.len();
212
213                        get_modifications(old_dir, &curr_dir, new, current_path.clone(), diff)?;
214
215                        // This directory or its contents were modified/added
216                        // Check if the new directory was deleted from new_etc
217                        // If it was, we want to add the directory back
218                        if new.get_directory_opt(&current_path.as_os_str())?.is_none() {
219                            if diff.added.len() != total_added {
220                                diff.added.insert(total_added, current_path.clone());
221                            } else if diff.modified.len() != total_modified {
222                                diff.modified.insert(total_modified, current_path.clone());
223                            }
224                        }
225                    }
226
227                    Err(ImageError::NotFound(..)) => {
228                        // Dir not found in original /etc, dir was added
229                        diff.added.push(current_path.clone());
230
231                        // Also add every file inside that dir
232                        collect_all_files(&curr_dir, current_path.clone(), &mut diff.added);
233                    }
234
235                    Err(ImageError::NotADirectory(..)) => {
236                        // Some directory was changed to a file/symlink
237                        // This should be counted in the diff, but we don't really merge this
238                        diff.modified.push(current_path.clone());
239                    }
240
241                    Err(e) => Err(e)?,
242                }
243            }
244
245            Inode::Leaf(leaf) => match pristine.ref_leaf(path) {
246                Ok(old_leaf) => {
247                    if !stat_eq_ignore_mtime(&old_leaf.stat, &leaf.stat) {
248                        diff.modified.push(current_path.clone());
249                        current_path.pop();
250                        continue;
251                    }
252
253                    match (&old_leaf.content, &leaf.content) {
254                        (Regular(old_meta), Regular(current_meta)) => {
255                            if old_meta.content_hash != current_meta.content_hash {
256                                // File modified in some way
257                                diff.modified.push(current_path.clone());
258                            }
259                        }
260
261                        (Symlink(old_link), Symlink(current_link)) => {
262                            if old_link != current_link {
263                                // Symlink modified in some way
264                                diff.modified.push(current_path.clone());
265                            }
266                        }
267
268                        (Symlink(..), Regular(..)) | (Regular(..), Symlink(..)) => {
269                            // File changed to symlink or vice-versa
270                            diff.modified.push(current_path.clone());
271                        }
272
273                        (a, b) => {
274                            unreachable!("{a:?} modified to {b:?}")
275                        }
276                    }
277                }
278
279                Err(ImageError::IsADirectory(..)) => {
280                    // A directory was changed to a file
281                    diff.modified.push(current_path.clone());
282                }
283
284                Err(ImageError::NotFound(..)) => {
285                    // File not found in original /etc, file was added
286                    diff.added.push(current_path.clone());
287                }
288
289                Err(e) => Err(e).context(format!("{path:?}"))?,
290            },
291        }
292
293        current_path.pop();
294    }
295
296    Ok(())
297}
298
299/// Traverses and collects directory trees for three etc states.
300///
301/// Recursively walks through the given *pristine*, *current*, and *new* etc directories,
302/// building filesystem trees that capture files, directories, and symlinks.
303/// Device files, sockets, pipes etc are ignored
304///
305/// It is primarily used to prepare inputs for later diff computations and
306/// comparisons between different etc states.
307///
308/// # Arguments
309///
310/// * `pristine_etc` - The reference directory representing the unmodified version or current /etc.
311/// Usually this will be obtained by remounting the EROFS image to a temporary location
312///
313/// * `current_etc` - The current `/etc` directory
314///
315/// * `new_etc` - The directory representing the `/etc` directory for a new deployment. This will
316/// again be usually obtained by mounting the new EROFS image to a temporary location. If merging
317/// it will be necessary to make the `/etc` for the deployment writeable
318///
319/// # Returns
320///
321/// [`anyhow::Result`] containing a tuple of directory trees in the order:
322///
323/// 1. `pristine_etc_files` – Dirtree of the pristine etc state
324/// 2. `current_etc_files`  – Dirtree of the current etc state
325/// 3. `new_etc_files`      – Dirtree of the new etc state (if new_etc directory is passed)
326pub fn traverse_etc(
327    pristine_etc: &CapStdDir,
328    current_etc: &CapStdDir,
329    new_etc: Option<&CapStdDir>,
330) -> anyhow::Result<(
331    Directory<CustomMetadata>,
332    Directory<CustomMetadata>,
333    Option<Directory<CustomMetadata>>,
334)> {
335    let mut pristine_etc_files = Directory::new(Stat::uninitialized());
336    recurse_dir(pristine_etc, &mut pristine_etc_files)
337        .context(format!("Recursing {pristine_etc:?}"))?;
338
339    let mut current_etc_files = Directory::new(Stat::uninitialized());
340    recurse_dir(current_etc, &mut current_etc_files)
341        .context(format!("Recursing {current_etc:?}"))?;
342
343    let new_etc_files = match new_etc {
344        Some(new_etc) => {
345            let mut new_etc_files = Directory::new(Stat::uninitialized());
346            recurse_dir(new_etc, &mut new_etc_files).context(format!("Recursing {new_etc:?}"))?;
347
348            Some(new_etc_files)
349        }
350
351        None => None,
352    };
353
354    return Ok((pristine_etc_files, current_etc_files, new_etc_files));
355}
356
357/// Computes the differences between two directory snapshots.
358#[context("Computing diff")]
359pub fn compute_diff(
360    pristine_etc_files: &Directory<CustomMetadata>,
361    current_etc_files: &Directory<CustomMetadata>,
362    new_etc_files: &Directory<CustomMetadata>,
363) -> anyhow::Result<Diff> {
364    let mut diff = Diff {
365        added: vec![],
366        modified: vec![],
367        removed: vec![],
368    };
369
370    get_modifications(
371        &pristine_etc_files,
372        &current_etc_files,
373        &new_etc_files,
374        PathBuf::new(),
375        &mut diff,
376    )?;
377
378    get_deletions(
379        &pristine_etc_files,
380        &current_etc_files,
381        PathBuf::new(),
382        &mut diff,
383    )?;
384
385    Ok(diff)
386}
387
388/// Prints a colorized summary of differences to standard output.
389pub fn print_diff(diff: &Diff, writer: &mut impl Write) {
390    use owo_colors::OwoColorize;
391
392    for added in &diff.added {
393        let _ = writeln!(writer, "{} {added:?}", ModificationType::Added.green());
394    }
395
396    for modified in &diff.modified {
397        let _ = writeln!(writer, "{} {modified:?}", ModificationType::Modified.cyan());
398    }
399
400    for removed in &diff.removed {
401        let _ = writeln!(writer, "{} {removed:?}", ModificationType::Removed.red());
402    }
403}
404
405#[context("Collecting xattrs")]
406fn collect_xattrs(etc_fd: &CapStdDir, rel_path: impl AsRef<Path>) -> anyhow::Result<Xattrs> {
407    let link = format!("/proc/self/fd/{}", etc_fd.as_fd().as_raw_fd());
408    let path = Path::new(&link).join(rel_path);
409
410    const DEFAULT_SIZE: usize = 128;
411
412    // Start with a guess for size
413    let mut xattrs_name_buf: Vec<u8> = vec![0; DEFAULT_SIZE];
414    let mut size = llistxattr(&path, &mut xattrs_name_buf).context("llistxattr")?;
415
416    if size > xattrs_name_buf.capacity() {
417        xattrs_name_buf.resize(size, 0);
418        size = llistxattr(&path, &mut xattrs_name_buf).context("llistxattr")?;
419    }
420
421    let xattrs: Xattrs = RefCell::new(BTreeMap::new());
422
423    for name_buf in xattrs_name_buf[..size]
424        .split(|&b| b == 0)
425        .filter(|x| !x.is_empty())
426    {
427        let name = OsStr::from_bytes(name_buf);
428
429        let mut xattrs_value_buf = vec![0; DEFAULT_SIZE];
430        let mut size = lgetxattr(&path, name_buf, &mut xattrs_value_buf).context("lgetxattr")?;
431
432        if size > xattrs_value_buf.capacity() {
433            xattrs_value_buf.resize(size, 0);
434            size = lgetxattr(&path, name_buf, &mut xattrs_value_buf).context("lgetxattr")?;
435        }
436
437        xattrs.borrow_mut().insert(
438            Box::<OsStr>::from(name),
439            Box::<[u8]>::from(&xattrs_value_buf[..size]),
440        );
441    }
442
443    Ok(xattrs)
444}
445
446#[context("Copying xattrs")]
447fn copy_xattrs(xattrs: &Xattrs, new_etc_fd: &CapStdDir, path: &Path) -> anyhow::Result<()> {
448    for (attr, value) in xattrs.borrow().iter() {
449        let fdpath = &Path::new(&format!("/proc/self/fd/{}", new_etc_fd.as_raw_fd())).join(path);
450        lsetxattr(fdpath, attr.as_ref(), value, XattrFlags::empty())
451            .with_context(|| format!("setxattr {attr:?} for {fdpath:?}"))?;
452    }
453
454    Ok(())
455}
456
457fn recurse_dir(dir: &CapStdDir, root: &mut Directory<CustomMetadata>) -> anyhow::Result<()> {
458    for entry in dir.entries()? {
459        let entry = entry.context(format!("Getting entry"))?;
460        let entry_name = entry.file_name();
461
462        let entry_type = entry.file_type()?;
463
464        let entry_meta = entry
465            .metadata()
466            .context(format!("Getting metadata for {entry_name:?}"))?;
467
468        let xattrs = collect_xattrs(&dir, &entry_name)?;
469
470        // Do symlinks first as we don't want to follow back up any symlinks
471        if entry_type.is_symlink() {
472            let readlinkat_result = readlinkat(&dir, &entry_name, vec![])
473                .context(format!("readlinkat {entry_name:?}"))?;
474
475            let os_str = OsStr::from_bytes(readlinkat_result.as_bytes());
476
477            root.insert(
478                &entry_name,
479                Inode::Leaf(Rc::new(Leaf {
480                    stat: MyStat::from((&entry_meta, xattrs)).0,
481                    content: LeafContent::Symlink(Box::from(os_str)),
482                })),
483            );
484
485            continue;
486        }
487
488        if entry_type.is_dir() {
489            let dir = dir
490                .open_dir(&entry_name)
491                .with_context(|| format!("Opening dir {entry_name:?} inside {dir:?}"))?;
492
493            let mut directory = Directory::new(MyStat::from((&entry_meta, xattrs)).0);
494
495            recurse_dir(&dir, &mut directory)?;
496
497            root.insert(&entry_name, Inode::Directory(Box::new(directory)));
498
499            continue;
500        }
501
502        if !(entry_type.is_symlink() || entry_type.is_file()) {
503            // We cannot read any other device like socket, pipe, fifo.
504            // We shouldn't really find these in /etc in the first place
505            tracing::debug!("Ignoring non-regular/non-symlink file: {:?}", entry_name);
506            continue;
507        }
508
509        // TODO: Another generic here but constrained to Sha256HashValue
510        // Regarding this, we'll definitely get DigestMismatch error if SHA512 is being used
511        // So we query the verity again if we get a DigestMismatch error
512        let measured_verity =
513            composefs::fsverity::measure_verity_opt::<Sha256HashValue>(entry.open()?);
514
515        let measured_verity = match measured_verity {
516            Ok(mv) => mv.map(|verity| verity.to_hex()),
517
518            Err(composefs::fsverity::MeasureVerityError::InvalidDigestAlgorithm { .. }) => {
519                composefs::fsverity::measure_verity_opt::<Sha512HashValue>(entry.open()?)?
520                    .map(|verity| verity.to_hex())
521            }
522
523            Err(e) => Err(e)?,
524        };
525
526        if let Some(measured_verity) = measured_verity {
527            root.insert(
528                &entry_name,
529                Inode::Leaf(Rc::new(Leaf {
530                    stat: MyStat::from((&entry_meta, xattrs)).0,
531                    content: LeafContent::Regular(CustomMetadata::new(
532                        "".into(),
533                        Some(measured_verity),
534                    )),
535                })),
536            );
537
538            continue;
539        }
540
541        let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?;
542
543        let file = entry
544            .open()
545            .context(format!("Opening entry {entry_name:?}"))?;
546
547        let mut reader = BufReader::new(file);
548        std::io::copy(&mut reader, &mut hasher)?;
549
550        let content_digest = hex::encode(hasher.finish()?);
551
552        root.insert(
553            &entry_name,
554            Inode::Leaf(Rc::new(Leaf {
555                stat: MyStat::from((&entry_meta, xattrs)).0,
556                content: LeafContent::Regular(CustomMetadata::new(content_digest, None)),
557            })),
558        );
559    }
560
561    Ok(())
562}
563
564#[derive(Debug)]
565enum ModificationType {
566    Added,
567    Modified,
568    Removed,
569}
570
571impl std::fmt::Display for ModificationType {
572    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
573        write!(f, "{:?}", self)
574    }
575}
576
577impl ModificationType {
578    fn symbol(&self) -> &'static str {
579        match self {
580            ModificationType::Added => "+",
581            ModificationType::Modified => "~",
582            ModificationType::Removed => "-",
583        }
584    }
585}
586
587fn create_dir_with_perms(
588    new_etc_fd: &CapStdDir,
589    dir_name: &PathBuf,
590    stat: &Stat,
591    new_inode: Option<&Inode<CustomMetadata>>,
592) -> anyhow::Result<()> {
593    // The new directory is not present in the new_etc, so we create it, else we only copy the
594    // metadata
595    if new_inode.is_none() {
596        // Here we use `create_dir_all` to create every parent as we will set the permissions later
597        // on. Due to the fact that we have an ordered (sorted) list of directories and directory
598        // entries and we have a DFS traversal, we will always have directory creation starting from
599        // the parent anyway.
600        //
601        // The exception being, if a directory is modified in the current_etc, and a new directory
602        // is added inside the modified directory, say `dir/prems` has its permissions modified and
603        // `dir/prems/new` is the new directory created. Since we handle added files/directories first,
604        // we will create the directories `perms/new` with directory `new` also getting its
605        // permissions set, but `perms` will not. `perms` will have its permissions set up when we
606        // handle the modified directories.
607        new_etc_fd
608            .create_dir_all(&dir_name)
609            .context(format!("Failed to create dir {dir_name:?}"))?;
610    }
611
612    new_etc_fd
613        .set_permissions(&dir_name, Permissions::from_mode(stat.st_mode))
614        .context(format!("Changing permissions for dir {dir_name:?}"))?;
615
616    rustix::fs::chownat(
617        &new_etc_fd,
618        dir_name,
619        Some(Uid::from_raw(stat.st_uid)),
620        Some(Gid::from_raw(stat.st_gid)),
621        AtFlags::SYMLINK_NOFOLLOW,
622    )
623    .context(format!("chown {dir_name:?}"))?;
624
625    copy_xattrs(&stat.xattrs, new_etc_fd, dir_name)?;
626
627    Ok(())
628}
629
630fn merge_leaf(
631    current_etc_fd: &CapStdDir,
632    new_etc_fd: &CapStdDir,
633    leaf: &Rc<Leaf<CustomMetadata>>,
634    new_inode: Option<&Inode<CustomMetadata>>,
635    file: &PathBuf,
636) -> anyhow::Result<()> {
637    let symlink = match &leaf.content {
638        LeafContent::Regular(..) => None,
639        LeafContent::Symlink(target) => Some(target),
640
641        _ => {
642            tracing::debug!("Found non file/symlink while merging. Ignoring");
643            return Ok(());
644        }
645    };
646
647    if matches!(new_inode, Some(Inode::Directory(..))) {
648        anyhow::bail!("Modified config file {file:?} newly defaults to directory. Cannot merge")
649    };
650
651    // If a new file with the same path exists, we delete it
652    new_etc_fd
653        .remove_all_optional(&file)
654        .context(format!("Deleting {file:?}"))?;
655
656    if let Some(target) = symlink {
657        // Using rustix's symlinkat here as we might have absolute symlinks which clash with ambient_authority
658        symlinkat(&**target, new_etc_fd, file).context(format!("Creating symlink {file:?}"))?;
659    } else {
660        current_etc_fd
661            .copy(&file, new_etc_fd, &file)
662            .with_context(|| format!("Copying file {file:?}"))?;
663    };
664
665    rustix::fs::chownat(
666        &new_etc_fd,
667        file,
668        Some(Uid::from_raw(leaf.stat.st_uid)),
669        Some(Gid::from_raw(leaf.stat.st_gid)),
670        AtFlags::SYMLINK_NOFOLLOW,
671    )
672    .context(format!("chown {file:?}"))?;
673
674    copy_xattrs(&leaf.stat.xattrs, new_etc_fd, file)?;
675
676    Ok(())
677}
678
679fn merge_modified_files(
680    files: &Vec<PathBuf>,
681    current_etc_fd: &CapStdDir,
682    current_etc_dirtree: &Directory<CustomMetadata>,
683    new_etc_fd: &CapStdDir,
684    new_etc_dirtree: &Directory<CustomMetadata>,
685) -> anyhow::Result<()> {
686    for file in files {
687        let (dir, filename) = current_etc_dirtree
688            .split(OsStr::new(&file))
689            .context("Getting directory and file")?;
690
691        let current_inode = dir
692            .lookup(filename)
693            .ok_or_else(|| anyhow::anyhow!("{filename:?} not found"))?;
694
695        // This will error out if some directory in a chain does not exist
696        let res = new_etc_dirtree.split(OsStr::new(&file));
697
698        match res {
699            Ok((new_dir, filename)) => {
700                let new_inode = new_dir.lookup(filename);
701
702                match current_inode {
703                    Inode::Directory(..) => {
704                        create_dir_with_perms(new_etc_fd, file, current_inode.stat(), new_inode)?;
705                    }
706
707                    Inode::Leaf(leaf) => {
708                        merge_leaf(current_etc_fd, new_etc_fd, leaf, new_inode, file)?
709                    }
710                };
711            }
712
713            // Directory/File does not exist in the new /etc
714            Err(ImageError::NotFound(..)) => match current_inode {
715                Inode::Directory(..) => {
716                    create_dir_with_perms(new_etc_fd, file, current_inode.stat(), None)?
717                }
718
719                Inode::Leaf(leaf) => {
720                    merge_leaf(current_etc_fd, new_etc_fd, leaf, None, file)?;
721                }
722            },
723
724            Err(e) => Err(e)?,
725        };
726    }
727
728    Ok(())
729}
730
731/// Goes through the added, modified, removed files and apply those changes to the new_etc
732/// This will overwrite, remove, modify files in new_etc
733/// Paths in `diff` are relative to `etc`
734#[context("Merging")]
735pub fn merge(
736    current_etc_fd: &CapStdDir,
737    current_etc_dirtree: &Directory<CustomMetadata>,
738    new_etc_fd: &CapStdDir,
739    new_etc_dirtree: &Directory<CustomMetadata>,
740    diff: &Diff,
741) -> anyhow::Result<()> {
742    merge_modified_files(
743        &diff.added,
744        current_etc_fd,
745        current_etc_dirtree,
746        new_etc_fd,
747        new_etc_dirtree,
748    )
749    .context("Merging added files")?;
750
751    merge_modified_files(
752        &diff.modified,
753        current_etc_fd,
754        current_etc_dirtree,
755        new_etc_fd,
756        new_etc_dirtree,
757    )
758    .context("Merging modified files")?;
759
760    for removed in &diff.removed {
761        let stat = new_etc_fd.metadata_optional(&removed)?;
762
763        let Some(stat) = stat else {
764            // File/dir doesn't exist in new_etc
765            // Basically a no-op
766            continue;
767        };
768
769        if stat.is_file() || stat.is_symlink() {
770            new_etc_fd.remove_file(&removed)?;
771        } else if stat.is_dir() {
772            // We only add the directory to the removed array, if the entire directory was deleted
773            // So `remove_dir_all` should be okay here
774            new_etc_fd.remove_dir_all(&removed)?;
775        }
776    }
777
778    Ok(())
779}
780
781#[cfg(test)]
782mod tests {
783    use cap_std::fs::PermissionsExt;
784    use cap_std_ext::cap_std::fs::Metadata;
785
786    use super::*;
787
788    const FILES: &[(&str, &str)] = &[
789        ("a/file1", "a-file1"),
790        ("a/file2", "a-file2"),
791        ("a/b/file1", "ab-file1"),
792        ("a/b/file2", "ab-file2"),
793        ("a/b/c/fileabc", "abc-file1"),
794        ("a/b/c/modify-perms", "modify-perms"),
795        ("a/b/c/to-be-removed", "remove this"),
796        ("to-be-removed", "remove this 2"),
797    ];
798
799    #[test]
800    fn test_etc_diff_plus_merge() -> anyhow::Result<()> {
801        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
802
803        tempdir.create_dir("pristine_etc")?;
804        tempdir.create_dir("current_etc")?;
805        tempdir.create_dir("new_etc")?;
806
807        let p = tempdir.open_dir("pristine_etc")?;
808        let c = tempdir.open_dir("current_etc")?;
809        let n = tempdir.open_dir("new_etc")?;
810
811        p.create_dir_all("a/b/c")?;
812        c.create_dir_all("a/b/c")?;
813
814        for (file, content) in FILES {
815            p.write(file, content.as_bytes())?;
816            c.write(file, content.as_bytes())?;
817        }
818
819        let new_files = ["new_file", "a/new_file", "a/b/c/new_file"];
820
821        // Add some new files
822        for file in new_files {
823            c.write(file, b"hello")?;
824        }
825
826        let overwritten_files = [FILES[1].0, FILES[4].0];
827        let perm_changed_files = [FILES[5].0];
828
829        // Modify some files
830        c.write(overwritten_files[0], b"some new content")?;
831        c.write(overwritten_files[1], b"some newer content")?;
832
833        // Modify permissions
834        let file = c.open(perm_changed_files[0])?;
835        // This should be enough as the usual files have permission 644
836        file.set_permissions(cap_std::fs::Permissions::from_mode(0o400))?;
837
838        // Remove some files
839        let deleted_files = [FILES[6].0, FILES[7].0];
840        c.remove_file(deleted_files[0])?;
841        c.remove_file(deleted_files[1])?;
842
843        let (pristine_etc_files, current_etc_files, new_etc_files) =
844            traverse_etc(&p, &c, Some(&n))?;
845
846        let res = compute_diff(
847            &pristine_etc_files,
848            &current_etc_files,
849            new_etc_files.as_ref().unwrap(),
850        )?;
851
852        merge(
853            &c,
854            &current_etc_files,
855            &n,
856            new_etc_files.as_ref().unwrap(),
857            &res,
858        )
859        .expect("Merge failed");
860
861        let added_dirs = ["a", "a/b", "a/b/c"];
862
863        // 3 for the files, and 3 for the directories
864        assert_eq!(res.added.len(), new_files.len() + added_dirs.len());
865
866        // Test modified files
867        let all_modified_files = overwritten_files
868            .iter()
869            .chain(&perm_changed_files)
870            .collect::<Vec<_>>();
871
872        assert_eq!(res.modified.len(), all_modified_files.len());
873        assert!(res.modified.iter().all(|file| {
874            all_modified_files
875                .iter()
876                .find(|x| PathBuf::from(*x) == *file)
877                .is_some()
878        }));
879
880        // Test removed files
881        assert_eq!(res.removed.len(), deleted_files.len());
882        assert!(res.removed.iter().all(|file| {
883            deleted_files
884                .iter()
885                .find(|x| PathBuf::from(*x) == *file)
886                .is_some()
887        }));
888
889        Ok(())
890    }
891
892    fn compare_meta(meta1: Metadata, meta2: Metadata) -> bool {
893        return meta1.is_file() == meta2.is_file()
894            && meta1.is_dir() == meta2.is_dir()
895            && meta1.is_symlink() == meta2.is_symlink()
896            && meta1.mode() == meta2.mode()
897            && meta1.uid() == meta2.uid()
898            && meta1.gid() == meta2.gid();
899    }
900
901    fn files_eq(current_etc: &CapStdDir, new_etc: &CapStdDir, path: &str) -> anyhow::Result<bool> {
902        return Ok(
903            compare_meta(current_etc.metadata(path)?, new_etc.metadata(path)?)
904                && current_etc.read(path)? == new_etc.read(path)?,
905        );
906    }
907
908    #[test]
909    fn test_merge() -> anyhow::Result<()> {
910        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
911
912        tempdir.create_dir("pristine_etc")?;
913        tempdir.create_dir("current_etc")?;
914        tempdir.create_dir("new_etc")?;
915
916        let p = tempdir.open_dir("pristine_etc")?;
917        let c = tempdir.open_dir("current_etc")?;
918        let n = tempdir.open_dir("new_etc")?;
919
920        p.create_dir_all("a/b")?;
921        c.create_dir_all("a/b")?;
922        n.create_dir_all("a/b")?;
923
924        // File added in current_etc, with file NOT present in new_etc
925        // arbitrary nesting
926        c.write("new_file.txt", "text1")?;
927        c.write("a/new_file.txt", "text2")?;
928        c.write("a/b/new_file.txt", "text3")?;
929
930        // File added in current_etc, with file present in new_etc
931        c.write("present_file.txt", "new-present-text1")?;
932        c.write("a/present_file.txt", "new-present-text2")?;
933        c.write("a/b/present_file.txt", "new-present-text3")?;
934
935        n.write("present_file.txt", "present-text1")?;
936        n.write("a/present_file.txt", "present-text2")?;
937        n.write("a/b/present_file.txt", "present-text3")?;
938
939        // File (content) modified in current_etc, with file NOT PRESENT in new_etc
940        p.write("content-modify.txt", "old-content1")?;
941        p.write("a/content-modify.txt", "old-content2")?;
942        p.write("a/b/content-modify.txt", "old-content3")?;
943
944        c.write("content-modify.txt", "new-content1")?;
945        c.write("a/content-modify.txt", "new-content2")?;
946        c.write("a/b/content-modify.txt", "new-content3")?;
947
948        // File (content) modified in current_etc, with file PRESENT in new_etc
949        p.write("content-modify-present.txt", "old-present-content1")?;
950        p.write("a/content-modify-present.txt", "old-present-content2")?;
951        p.write("a/b/content-modify-present.txt", "old-present-content3")?;
952
953        c.write("content-modify-present.txt", "current-present-content1")?;
954        c.write("a/content-modify-present.txt", "current-present-content2")?;
955        c.write("a/b/content-modify-present.txt", "current-present-content3")?;
956
957        n.write("content-modify-present.txt", "new-present-content1")?;
958        n.write("a/content-modify-present.txt", "new-present-content2")?;
959        n.write("a/b/content-modify-present.txt", "new-present-content3")?;
960
961        // File (permission) modified in current_etc, with file NOT PRESENT in new_etc
962        p.write("permission-modify.txt", "old-content1")?;
963        p.write("a/permission-modify.txt", "old-content2")?;
964        p.write("a/b/permission-modify.txt", "old-content3")?;
965
966        c.atomic_write_with_perms(
967            "permission-modify.txt",
968            "old-content1",
969            Permissions::from_mode(0o755),
970        )?;
971        c.atomic_write_with_perms(
972            "a/permission-modify.txt",
973            "old-content2",
974            Permissions::from_mode(0o766),
975        )?;
976        c.atomic_write_with_perms(
977            "a/b/permission-modify.txt",
978            "old-content3",
979            Permissions::from_mode(0o744),
980        )?;
981
982        // File (permission) modified in current_etc, with file PRESENT in new_etc
983        p.write("permission-modify-present.txt", "old-present-content1")?;
984        p.write("a/permission-modify-present.txt", "old-present-content2")?;
985        p.write("a/b/permission-modify-present.txt", "old-present-content3")?;
986
987        c.atomic_write_with_perms(
988            "permission-modify-present.txt",
989            "old-present-content1",
990            Permissions::from_mode(0o755),
991        )?;
992        c.atomic_write_with_perms(
993            "a/permission-modify-present.txt",
994            "old-present-content2",
995            Permissions::from_mode(0o766),
996        )?;
997        c.atomic_write_with_perms(
998            "a/b/permission-modify-present.txt",
999            "old-present-content3",
1000            Permissions::from_mode(0o744),
1001        )?;
1002
1003        n.write("permission-modify-present.txt", "new-present-content1")?;
1004        n.write("a/permission-modify-present.txt", "old-present-content2")?;
1005        n.write("a/b/permission-modify-present.txt", "new-present-content3")?;
1006
1007        // Create a new dirtree
1008        c.create_dir_all("new/dir/tree/here")?;
1009
1010        // Create a new dirtree in an already existing dirtree
1011        p.create_dir_all("existing/tree")?;
1012        c.create_dir_all("existing/tree/another/dir/tree")?;
1013        c.write(
1014            "existing/tree/another/dir/tree/file.txt",
1015            "dir-tree-contents",
1016        )?;
1017
1018        // Directory permissions
1019        p.create_dir_all("dir/perms")?;
1020        p.create_dir_all("dir/perms/wo")?;
1021        p.create_dir_all("dir/perms/wo/ro")?;
1022
1023        c.create_dir_all("dir/perms")?;
1024        c.set_permissions("dir/perms", Permissions::from_mode(0o777))?;
1025
1026        c.create_dir_all("dir/perms/rwx")?;
1027        c.set_permissions("dir/perms/rwx", Permissions::from_mode(0o777))?;
1028
1029        c.create_dir_all("dir/perms/wo")?;
1030        c.set_permissions("dir/perms/wo", Permissions::from_mode(0o733))?;
1031
1032        c.create_dir_all("dir/perms/wo/ro")?;
1033        c.set_permissions("dir/perms/wo/ro", Permissions::from_mode(0o775))?;
1034
1035        n.create_dir_all("dir/perms")?;
1036        n.write("dir/perms/some-file", "Some-file")?;
1037
1038        let (pristine_etc_files, current_etc_files, new_etc_files) =
1039            traverse_etc(&p, &c, Some(&n))?;
1040        let diff = compute_diff(
1041            &pristine_etc_files,
1042            &current_etc_files,
1043            &new_etc_files.as_ref().unwrap(),
1044        )?;
1045        merge(&c, &current_etc_files, &n, &new_etc_files.unwrap(), &diff)?;
1046
1047        assert!(files_eq(&c, &n, "new_file.txt")?);
1048        assert!(files_eq(&c, &n, "a/new_file.txt")?);
1049        assert!(files_eq(&c, &n, "a/b/new_file.txt")?);
1050
1051        assert!(files_eq(&c, &n, "present_file.txt")?);
1052        assert!(files_eq(&c, &n, "a/present_file.txt")?);
1053        assert!(files_eq(&c, &n, "a/b/present_file.txt")?);
1054
1055        assert!(files_eq(&c, &n, "content-modify.txt")?);
1056        assert!(files_eq(&c, &n, "a/content-modify.txt")?);
1057        assert!(files_eq(&c, &n, "a/b/content-modify.txt")?);
1058
1059        assert!(files_eq(&c, &n, "content-modify-present.txt")?);
1060        assert!(files_eq(&c, &n, "a/content-modify-present.txt")?);
1061        assert!(files_eq(&c, &n, "a/b/content-modify-present.txt")?);
1062
1063        assert!(files_eq(&c, &n, "permission-modify.txt")?);
1064        assert!(files_eq(&c, &n, "a/permission-modify.txt")?);
1065        assert!(files_eq(&c, &n, "a/b/permission-modify.txt")?);
1066
1067        assert!(files_eq(&c, &n, "permission-modify-present.txt")?);
1068        assert!(files_eq(&c, &n, "a/permission-modify-present.txt")?);
1069        assert!(files_eq(&c, &n, "a/b/permission-modify-present.txt")?);
1070
1071        assert!(n.exists("new/dir/tree/here"));
1072        assert!(n.exists("existing/tree/another/dir/tree"));
1073        assert!(files_eq(&c, &n, "existing/tree/another/dir/tree/file.txt")?);
1074
1075        assert!(compare_meta(
1076            c.metadata("dir/perms")?,
1077            n.metadata("dir/perms")?
1078        ));
1079
1080        // Make sure nothing is deleted from a directory
1081        assert!(n.exists("dir/perms/some-file"));
1082
1083        const DIR_BITS: u32 = 0o040000;
1084
1085        assert_eq!(
1086            n.metadata("dir/perms/rwx").unwrap().mode(),
1087            DIR_BITS | 0o777
1088        );
1089        assert_eq!(n.metadata("dir/perms/wo").unwrap().mode(), DIR_BITS | 0o733);
1090        assert_eq!(
1091            n.metadata("dir/perms/wo/ro").unwrap().mode(),
1092            DIR_BITS | 0o775
1093        );
1094
1095        Ok(())
1096    }
1097
1098    #[test]
1099    fn file_to_dir() -> anyhow::Result<()> {
1100        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1101
1102        tempdir.create_dir("pristine_etc")?;
1103        tempdir.create_dir("current_etc")?;
1104        tempdir.create_dir("new_etc")?;
1105
1106        let p = tempdir.open_dir("pristine_etc")?;
1107        let c = tempdir.open_dir("current_etc")?;
1108        let n = tempdir.open_dir("new_etc")?;
1109
1110        p.write("file-to-dir", "some text")?;
1111        c.write("file-to-dir", "some text 1")?;
1112
1113        n.create_dir_all("file-to-dir")?;
1114
1115        let (pristine_etc_files, current_etc_files, new_etc_files) =
1116            traverse_etc(&p, &c, Some(&n))?;
1117        let diff = compute_diff(
1118            &pristine_etc_files,
1119            &current_etc_files,
1120            &new_etc_files.as_ref().unwrap(),
1121        )?;
1122
1123        let merge_res = merge(&c, &current_etc_files, &n, &new_etc_files.unwrap(), &diff);
1124
1125        assert!(merge_res.is_err());
1126        assert_eq!(
1127            merge_res.unwrap_err().root_cause().to_string(),
1128            "Modified config file \"file-to-dir\" newly defaults to directory. Cannot merge"
1129        );
1130
1131        Ok(())
1132    }
1133}