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