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