Skip to content

Commit 6ae1ace

Browse files
etc-merge: Handle unmergable paths properly
We would have unmergable paths if a modified file was changed to a directory in the new etc or vice-versa. One of the major issues was that we were erroring out during "merge" which is not ideal. A new vector now keeps track of all unmergable paths and the reason they're not mergable. Ref: composefs/composefs-rs/issues/335 Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
1 parent 2aa413b commit 6ae1ace

1 file changed

Lines changed: 134 additions & 30 deletions

File tree

crates/etc-merge/src/lib.rs

Lines changed: 134 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ fn stat_eq_ignore_mtime(this: &Stat, other: &Stat) -> bool {
7878
return true;
7979
}
8080

81+
#[derive(Debug)]
82+
pub struct UnmergablePaths {
83+
path: PathBuf,
84+
reason: String,
85+
}
86+
8187
/// Represents the differences between two directory trees.
8288
#[derive(Debug)]
8389
pub struct Diff {
@@ -88,6 +94,8 @@ pub struct Diff {
8894
modified: Vec<PathBuf>,
8995
/// Paths that exist in the pristine /etc but not in the current one
9096
removed: Vec<PathBuf>,
97+
/// Paths that are unmergable
98+
unmergable_paths: Vec<UnmergablePaths>,
9199
}
92100

93101
fn collect_all_files(
@@ -170,6 +178,47 @@ fn get_deletions(
170178
Ok(())
171179
}
172180

181+
fn check_if_mergable(
182+
new: &Directory<CustomMetadata>,
183+
current_inode: &Inode<CustomMetadata>,
184+
current_path: &PathBuf,
185+
diff: &mut Diff,
186+
) {
187+
match current_inode {
188+
// If currently 'file' is a directory, make sure it's not a regular file
189+
// new_etc as well, else we can't merge
190+
Inode::Directory(..) => {
191+
let new_dir = new.get_directory(&current_path.as_os_str());
192+
193+
if matches!(new_dir, Err(ImageError::NotADirectory(..))) {
194+
diff.unmergable_paths.push(UnmergablePaths {
195+
path: current_path.clone(),
196+
reason: format!(
197+
"Directory '{}' now defaults to a file in new etc",
198+
current_path.display()
199+
),
200+
});
201+
}
202+
}
203+
204+
// If currently 'file' is not a directory, make sure it's not a directory in the
205+
// new_etc either, else we can't merge
206+
Inode::Leaf(..) => {
207+
let new_dir = new.get_directory(&current_path.as_os_str());
208+
209+
if matches!(new_dir, Ok(..)) {
210+
diff.unmergable_paths.push(UnmergablePaths {
211+
path: current_path.clone(),
212+
reason: format!(
213+
"File '{}' now defaults to a directory in new etc",
214+
current_path.display()
215+
),
216+
});
217+
}
218+
}
219+
}
220+
}
221+
173222
// 1. Files in the currently booted deployment’s /etc which were modified from the default /usr/etc (of the same deployment) are retained.
174223
//
175224
// 2. Files in the currently booted deployment’s /etc which were not modified from the default /usr/etc (of the same deployment)
@@ -190,6 +239,7 @@ fn get_modifications(
190239
current: &Directory<CustomMetadata>,
191240
pristine_leaves: &[Leaf<CustomMetadata>],
192241
current_leaves: &[Leaf<CustomMetadata>],
242+
new_leaves: &[Leaf<CustomMetadata>],
193243
new: &Directory<CustomMetadata>,
194244
mut current_path: PathBuf,
195245
diff: &mut Diff,
@@ -216,26 +266,45 @@ fn get_modifications(
216266
&curr_dir,
217267
pristine_leaves,
218268
current_leaves,
269+
new_leaves,
219270
new,
220271
current_path.clone(),
221272
diff,
222273
)?;
223274

224-
// This directory or its contents were modified/added
225-
// Check if the new directory was deleted from new_etc
226-
// If it was, we want to add the directory back
227-
if new.get_directory_opt(&current_path.as_os_str())?.is_none() {
228-
if diff.added.len() != total_added {
229-
diff.added.insert(total_added, current_path.clone());
230-
} else if diff.modified.len() != total_modified {
231-
diff.modified.insert(total_modified, current_path.clone());
275+
match new.get_directory(&current_path.as_os_str()) {
276+
Ok(..) => {
277+
// Directory exists in both current and new etc.
278+
// Modifications/additions within this directory are handled recursively.
279+
// No additional action needed here.
232280
}
281+
282+
Err(ImageError::NotFound(..)) | Err(ImageError::NotADirectory(..)) => {
283+
// This directory was deleted in new_etc
284+
// If it was modified in the current etc, we want this back
285+
//
286+
// Was a directory in current etc, but is now
287+
// a file/symlink in the new etc
288+
if diff.added.len() != total_added {
289+
diff.added.insert(total_added, current_path.clone());
290+
} else if diff.modified.len() != total_modified {
291+
diff.modified.insert(total_added, current_path.clone());
292+
}
293+
}
294+
295+
Err(e) => Err(e)?,
296+
}
297+
298+
if diff.modified.len() != total_modified || diff.added.len() != total_added
299+
{
300+
check_if_mergable(new, inode, &current_path, diff);
233301
}
234302
}
235303

236304
Err(ImageError::NotFound(..)) => {
237305
// Dir not found in original /etc, dir was added
238306
diff.added.push(current_path.clone());
307+
check_if_mergable(new, inode, &current_path, diff);
239308

240309
// Also add every file inside that dir
241310
collect_all_files(&curr_dir, current_path.clone(), &mut diff.added);
@@ -245,18 +314,21 @@ fn get_modifications(
245314
// Some directory was changed to a file/symlink
246315
// This should be counted in the diff, but we don't really merge this
247316
diff.modified.push(current_path.clone());
317+
check_if_mergable(new, inode, &current_path, diff);
248318
}
249319

250-
Err(e) => Err(e)?,
320+
Err(e) => Err(e).with_context(|| format!("Opening pristine {path:?}"))?,
251321
}
252322
}
253323

254324
Inode::Leaf(leaf_id, _) => match pristine.leaf_id(path) {
255325
Ok(old_leaf_id) => {
256326
let leaf = &current_leaves[leaf_id.0];
257327
let old_leaf = &pristine_leaves[old_leaf_id.0];
328+
258329
if !stat_eq_ignore_mtime(&old_leaf.stat, &leaf.stat) {
259330
diff.modified.push(current_path.clone());
331+
check_if_mergable(new, inode, &current_path, diff);
260332
current_path.pop();
261333
continue;
262334
}
@@ -266,19 +338,22 @@ fn get_modifications(
266338
if old_meta.content_hash != current_meta.content_hash {
267339
// File modified in some way
268340
diff.modified.push(current_path.clone());
341+
check_if_mergable(new, inode, &current_path, diff);
269342
}
270343
}
271344

272345
(Symlink(old_link), Symlink(current_link)) => {
273346
if old_link != current_link {
274347
// Symlink modified in some way
275348
diff.modified.push(current_path.clone());
349+
check_if_mergable(new, inode, &current_path, diff);
276350
}
277351
}
278352

279353
(Symlink(..), Regular(..)) | (Regular(..), Symlink(..)) => {
280354
// File changed to symlink or vice-versa
281355
diff.modified.push(current_path.clone());
356+
check_if_mergable(new, inode, &current_path, diff);
282357
}
283358

284359
(a, b) => {
@@ -290,14 +365,16 @@ fn get_modifications(
290365
Err(ImageError::IsADirectory(..)) => {
291366
// A directory was changed to a file
292367
diff.modified.push(current_path.clone());
368+
check_if_mergable(new, inode, &current_path, diff);
293369
}
294370

295371
Err(ImageError::NotFound(..)) => {
296372
// File not found in original /etc, file was added
297373
diff.added.push(current_path.clone());
374+
check_if_mergable(new, inode, &current_path, diff);
298375
}
299376

300-
Err(e) => Err(e).context(format!("{path:?}"))?,
377+
Err(e) => Err(e).with_context(|| format!("Opening pristine {path:?}"))?,
301378
},
302379
}
303380

@@ -385,13 +462,15 @@ pub fn compute_diff(
385462
added: vec![],
386463
modified: vec![],
387464
removed: vec![],
465+
unmergable_paths: vec![],
388466
};
389467

390468
get_modifications(
391469
&pristine_etc_files.root,
392470
&current_etc_files.root,
393471
&pristine_etc_files.leaves,
394472
&current_etc_files.leaves,
473+
&new_etc_files.leaves,
395474
&new_etc_files.root,
396475
PathBuf::new(),
397476
&mut diff,
@@ -422,6 +501,15 @@ pub fn print_diff(diff: &Diff, writer: &mut impl Write) {
422501
for removed in &diff.removed {
423502
let _ = writeln!(writer, "{} {removed:?}", ModificationType::Removed.red());
424503
}
504+
505+
for unmergable in &diff.unmergable_paths {
506+
let _ = writeln!(
507+
writer,
508+
"{} {}",
509+
ModificationType::Unmergable.magenta(),
510+
unmergable.reason
511+
);
512+
}
425513
}
426514

427515
#[context("Collecting xattrs")]
@@ -589,6 +677,7 @@ enum ModificationType {
589677
Added,
590678
Modified,
591679
Removed,
680+
Unmergable,
592681
}
593682

594683
impl std::fmt::Display for ModificationType {
@@ -603,6 +692,7 @@ impl ModificationType {
603692
ModificationType::Added => "+",
604693
ModificationType::Modified => "~",
605694
ModificationType::Removed => "-",
695+
ModificationType::Unmergable => "*",
606696
}
607697
}
608698
}
@@ -613,23 +703,35 @@ fn create_dir_with_perms(
613703
stat: &Stat,
614704
new_inode: Option<&Inode<CustomMetadata>>,
615705
) -> anyhow::Result<()> {
616-
// The new directory is not present in the new_etc, so we create it, else we only copy the
617-
// metadata
618-
if new_inode.is_none() {
619-
// Here we use `create_dir_all` to create every parent as we will set the permissions later
620-
// on. Due to the fact that we have an ordered (sorted) list of directories and directory
621-
// entries and we have a DFS traversal, we will always have directory creation starting from
622-
// the parent anyway.
623-
//
624-
// The exception being, if a directory is modified in the current_etc, and a new directory
625-
// is added inside the modified directory, say `dir/prems` has its permissions modified and
626-
// `dir/prems/new` is the new directory created. Since we handle added files/directories first,
627-
// we will create the directories `perms/new` with directory `new` also getting its
628-
// permissions set, but `perms` will not. `perms` will have its permissions set up when we
629-
// handle the modified directories.
630-
new_etc_fd
631-
.create_dir_all(&dir_name)
632-
.context(format!("Failed to create dir {dir_name:?}"))?;
706+
match new_inode {
707+
Some(inode) => match inode {
708+
Inode::Directory(..) => { /* no-op */ }
709+
710+
Inode::Leaf(..) => {
711+
anyhow::bail!(
712+
"Modified config directory {dir_name:?} newly defaults to file. Cannot merge"
713+
)
714+
}
715+
},
716+
717+
// The new directory is not present in the new_etc, so we create it, else we only copy the
718+
// metadata
719+
None => {
720+
// Here we use `create_dir_all` to create every parent as we will set the permissions later
721+
// on. Due to the fact that we have an ordered (sorted) list of directories and directory
722+
// entries and we have a DFS traversal, we will always have directory creation starting from
723+
// the parent anyway.
724+
//
725+
// The exception being, if a directory is modified in the current_etc, and a new directory
726+
// is added inside the modified directory, say `dir/prems` has its permissions modified and
727+
// `dir/prems/new` is the new directory created. Since we handle added files/directories first,
728+
// we will create the directories `perms/new` with directory `new` also getting its
729+
// permissions set, but `perms` will not. `perms` will have its permissions set up when we
730+
// handle the modified directories.
731+
new_etc_fd
732+
.create_dir_all(&dir_name)
733+
.context(format!("Failed to create dir {dir_name:?}"))?;
734+
}
633735
}
634736

635737
new_etc_fd
@@ -730,12 +832,14 @@ fn merge_modified_files(
730832
file,
731833
current_inode.stat(current_leaves),
732834
new_inode,
733-
)?;
835+
)
836+
.context("Merging directory")?;
734837
}
735838

736839
Inode::Leaf(leaf_id, _) => {
737840
let leaf = &current_leaves[leaf_id.0];
738-
merge_leaf(current_etc_fd, new_etc_fd, leaf, new_inode, file)?
841+
merge_leaf(current_etc_fd, new_etc_fd, leaf, new_inode, file)
842+
.context("Merging leaf")?
739843
}
740844
};
741845
}
@@ -755,7 +859,7 @@ fn merge_modified_files(
755859
}
756860
},
757861

758-
Err(e) => Err(e)?,
862+
Err(e) => Err(e).with_context(|| format!("Opening {file:?} in new etc"))?,
759863
};
760864
}
761865

0 commit comments

Comments
 (0)