Skip to main content

etc_merge/
lib.rs

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