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