Skip to content

Commit f3f74c9

Browse files
etc-merge: Use llistxattr and lgetxattr
Use the non symlink following counterparts for getting xattrs. Document public functions and structures Signed-off-by: Johan-Liebert1 <[email protected]>
1 parent 1f06e83 commit f3f74c9

File tree

3 files changed

+119
-46
lines changed

3 files changed

+119
-46
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/etc-merge/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ hex = { workspace = true }
1212
tracing = { workspace = true }
1313
composefs = { workspace = true }
1414
fn-error-context = { workspace = true }
15+
owo-colors = { workspace = true }
16+
anstream = { workspace = true }
1517

1618
[lints]
1719
workspace = true

crates/etc-merge/src/lib.rs

Lines changed: 115 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::cell::RefCell;
77
use std::collections::BTreeMap;
88
use std::ffi::{OsStr, OsString};
99
use std::io::BufReader;
10+
use std::io::Write;
1011
use std::os::fd::{AsFd, AsRawFd};
1112
use std::os::unix::ffi::OsStrExt;
1213
use std::path::{Path, PathBuf};
@@ -19,11 +20,14 @@ use cap_std_ext::dirext::CapStdExtDirExt;
1920
use composefs::fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue};
2021
use composefs::generic_tree::{Directory, Inode, Leaf, LeafContent, Stat};
2122
use composefs::tree::ImageError;
22-
use rustix::fs::{AtFlags, Gid, Uid, XattrFlags, getxattr, listxattr, lsetxattr, readlinkat};
23+
use rustix::fs::{lgetxattr, llistxattr, lsetxattr, readlinkat, AtFlags, Gid, Uid, XattrFlags};
2324

25+
/// Metadata associated with a file, directory, or symlink entry.
2426
#[derive(Debug)]
25-
struct CustomMetadata {
27+
pub struct CustomMetadata {
28+
/// A SHA256 sum representing the file contents.
2629
content_hash: String,
30+
/// Optional verity for the file
2731
verity: Option<String>,
2832
}
2933

@@ -72,14 +76,18 @@ fn stat_eq_ignore_mtime(this: &Stat, other: &Stat) -> bool {
7276
return true;
7377
}
7478

79+
/// Represents the differences between two directory trees.
7580
#[derive(Debug)]
76-
struct Diff {
81+
pub struct Diff {
82+
/// Paths that exist in the current /etc but not in the pristine
7783
added: Vec<PathBuf>,
84+
/// Paths that exist in both pristine and current /etc but differ in metadata
85+
/// (e.g., file contents, permissions, symlink targets)
7886
modified: Vec<PathBuf>,
87+
/// Paths that exist in the pristine /etc but not in the current one
7988
removed: Vec<PathBuf>,
8089
}
8190

82-
// if /outer/inner.txt is removed, then we only add /outer iff it's empty
8391
fn collect_all_files(root: &Directory<CustomMetadata>, current_path: PathBuf) -> Vec<PathBuf> {
8492
fn collect(
8593
root: &Directory<CustomMetadata>,
@@ -132,7 +140,7 @@ fn get_deletions(
132140

133141
Inode::Leaf(..) => match current.ref_leaf(file_name) {
134142
Ok(..) => {
135-
// Empty as all additions/modifications are tracked above
143+
// Empty as all additions/modifications are tracked earlier in `get_modifications`
136144
}
137145

138146
Err(ImageError::NotFound(..)) => {
@@ -170,6 +178,8 @@ fn get_modifications(
170178
mut current_path: PathBuf,
171179
diff: &mut Diff,
172180
) -> anyhow::Result<()> {
181+
use composefs::generic_tree::LeafContent::*;
182+
173183
for (path, inode) in current.sorted_entries() {
174184
current_path.push(path);
175185

@@ -200,19 +210,33 @@ fn get_modifications(
200210

201211
Inode::Leaf(leaf) => match pristine.ref_leaf(path) {
202212
Ok(old_leaf) => {
203-
let LeafContent::Regular(current_meta) = &leaf.content else {
204-
unreachable!("File types do not match");
205-
};
206-
207-
let LeafContent::Regular(old_meta) = &old_leaf.content else {
208-
unreachable!("File types do not match");
209-
};
210-
211-
if old_meta.content_hash != current_meta.content_hash
212-
|| !stat_eq_ignore_mtime(&old_leaf.stat, &leaf.stat)
213-
{
214-
// File modified in some way
215-
diff.modified.push(current_path.clone());
213+
match (&old_leaf.content, &leaf.content) {
214+
(Regular(old_meta), Regular(current_meta)) => {
215+
if old_meta.content_hash != current_meta.content_hash
216+
|| !stat_eq_ignore_mtime(&old_leaf.stat, &leaf.stat)
217+
{
218+
// File modified in some way
219+
diff.modified.push(current_path.clone());
220+
}
221+
}
222+
223+
(Symlink(old_link), Symlink(current_link)) => {
224+
if old_link != current_link
225+
|| !stat_eq_ignore_mtime(&old_leaf.stat, &leaf.stat)
226+
{
227+
// Symlink modified in some way
228+
diff.modified.push(current_path.clone());
229+
}
230+
}
231+
232+
(Symlink(..), Regular(..)) | (Regular(..), Symlink(..)) => {
233+
// File changed to symlink or vice-versa
234+
diff.modified.push(current_path.clone());
235+
}
236+
237+
(a, b) => {
238+
unreachable!("{a:?} modified to {b:?}")
239+
}
216240
}
217241
}
218242

@@ -236,8 +260,34 @@ fn get_modifications(
236260
Ok(())
237261
}
238262

239-
/// (Pristine, Current, New)
240-
fn traverse_etc(
263+
/// Traverses and collects directory trees for three etc states.
264+
///
265+
/// Recursively walks through the given *pristine*, *current*, and *new* etc directories,
266+
/// building filesystem trees that capture files, directories, and symlinks.
267+
/// Device files, sockets, pipes etc are ignored
268+
///
269+
/// It is primarily used to prepare inputs for later diff computations and
270+
/// comparisons between different etc states.
271+
///
272+
/// # Arguments
273+
///
274+
/// * `pristine_etc` - The reference directory representing the unmodified version or current /etc.
275+
/// Usually this will be obtained by remounting the EROFS image to a temporary location
276+
///
277+
/// * `current_etc` - The current `/etc` directory
278+
///
279+
/// * `new_etc` - The directory representing the `/etc` directory for a new deployment. This will
280+
/// again be usually obtained by mounting the new EROFS image to a temporary location. If merging
281+
/// it will be necessary to make the `/etc` for the deployment writeable
282+
///
283+
/// # Returns
284+
///
285+
/// [`anyhow::Result`] containing a tuple of directory trees in the order:
286+
///
287+
/// 1. `pristine_etc_files` – Dirtree of the pristine etc state
288+
/// 2. `current_etc_files` – Dirtree of the current etc state
289+
/// 3. `new_etc_files` – Dirtree of the new etc state
290+
pub fn traverse_etc(
241291
pristine_etc: &CapStdDir,
242292
current_etc: &CapStdDir,
243293
new_etc: &CapStdDir,
@@ -260,7 +310,8 @@ fn traverse_etc(
260310
return Ok((pristine_etc_files, current_etc_files, new_etc_files));
261311
}
262312

263-
fn compute_diff(
313+
/// Computes the differences between two directory snapshots.
314+
pub fn compute_diff(
264315
pristine_etc_files: &Directory<CustomMetadata>,
265316
current_etc_files: &Directory<CustomMetadata>,
266317
) -> anyhow::Result<Diff> {
@@ -287,6 +338,25 @@ fn compute_diff(
287338
Ok(diff)
288339
}
289340

341+
/// Prints a colorized summary of differences to standard output.
342+
pub fn print_diff(diff: &Diff) {
343+
use owo_colors::OwoColorize;
344+
345+
let mut stdout = anstream::stdout();
346+
347+
for added in &diff.added {
348+
let _ = writeln!(stdout, "{} {added:?}", ModificationType::Added.green());
349+
}
350+
351+
for modified in &diff.modified {
352+
let _ = writeln!(stdout, "{} {modified:?}", ModificationType::Modified.cyan());
353+
}
354+
355+
for removed in &diff.removed {
356+
let _ = writeln!(stdout, "{} {removed:?}", ModificationType::Removed.red());
357+
}
358+
}
359+
290360
#[context("Collecting xattrs")]
291361
fn collect_xattrs(etc_fd: &CapStdDir, rel_path: &OsString) -> anyhow::Result<Xattrs> {
292362
let link = format!("/proc/self/fd/{}", etc_fd.as_fd().as_raw_fd());
@@ -296,27 +366,24 @@ fn collect_xattrs(etc_fd: &CapStdDir, rel_path: &OsString) -> anyhow::Result<Xat
296366

297367
// Start with a guess for size
298368
let mut buf: Vec<u8> = vec![0; DEFAULT_SIZE];
299-
let size = listxattr(&path, &mut buf).context("listxattr")?;
369+
let size = llistxattr(&path, &mut buf).context("llistxattr")?;
300370

301371
if size > DEFAULT_SIZE {
302372
buf = vec![0; size];
303-
listxattr(&path, &mut buf).context("listxattr")?;
373+
llistxattr(&path, &mut buf).context("llistxattr")?;
304374
}
305375

306376
let xattrs: Xattrs = RefCell::new(BTreeMap::new());
307377

308-
for name_buf in buf[..size]
309-
.split_inclusive(|&b| b == 0)
310-
.filter(|x| !x.is_empty())
311-
{
378+
for name_buf in buf[..size].split(|&b| b == 0).filter(|x| !x.is_empty()) {
312379
let name = OsStr::from_bytes(name_buf);
313380

314381
let mut buf = vec![0; DEFAULT_SIZE];
315-
let size = getxattr(&path, name_buf, &mut buf).context("getxattr")?;
382+
let size = lgetxattr(&path, name_buf, &mut buf).context("lgetxattr")?;
316383

317384
if size > DEFAULT_SIZE {
318385
buf = vec![0; size];
319-
getxattr(&path, name_buf, &mut buf).context("getxattr")?;
386+
lgetxattr(&path, name_buf, &mut buf).context("lgetxattr")?;
320387
}
321388

322389
xattrs
@@ -345,12 +412,31 @@ fn recurse_dir(dir: &CapStdDir, root: &mut Directory<CustomMetadata>) -> anyhow:
345412
let entry_name = entry.file_name();
346413

347414
let entry_type = entry.file_type()?;
415+
348416
let entry_meta = entry
349417
.metadata()
350418
.context(format!("Getting metadata for {entry_name:?}"))?;
351419

352420
let xattrs = collect_xattrs(&dir, &entry_name)?;
353421

422+
// Do symlinks first as we don't want to follow back up any symlinks
423+
if entry_type.is_symlink() {
424+
let readlinkat_result = readlinkat(&dir, &entry_name, vec![])
425+
.context(format!("readlinkat {entry_name:?}"))?;
426+
427+
let os_str = OsStr::from_bytes(readlinkat_result.as_bytes());
428+
429+
root.insert(
430+
&entry_name,
431+
Inode::Leaf(Rc::new(Leaf {
432+
stat: MyStat::from((&entry_meta, xattrs)).0,
433+
content: LeafContent::Symlink(Box::from(os_str)),
434+
})),
435+
);
436+
437+
continue;
438+
}
439+
354440
if entry_type.is_dir() {
355441
let dir = dir
356442
.open_dir(&entry_name)
@@ -372,23 +458,6 @@ fn recurse_dir(dir: &CapStdDir, root: &mut Directory<CustomMetadata>) -> anyhow:
372458
continue;
373459
}
374460

375-
if entry_type.is_symlink() {
376-
let readlinkat_result = readlinkat(&dir, &entry_name, vec![])
377-
.context(format!("readlinkat {entry_name:?}"))?;
378-
379-
let os_str = OsStr::from_bytes(readlinkat_result.as_bytes());
380-
381-
root.insert(
382-
&entry_name,
383-
Inode::Leaf(Rc::new(Leaf {
384-
stat: MyStat::from((&entry_meta, xattrs)).0,
385-
content: LeafContent::Symlink(Box::from(os_str)),
386-
})),
387-
);
388-
389-
continue;
390-
}
391-
392461
// TODO: Another generic here but constrained to Sha256HashValue
393462
// Regarding this, we'll definitely get DigestMismatch error if SHA512 is being used
394463
// So we query the verity again if we get a DigestMismatch error

0 commit comments

Comments
 (0)