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