Skip to content

Commit 37d6b19

Browse files
Initial implementaion for /etc merge
Signed-off-by: Johan-Liebert1 <[email protected]>
1 parent 7d596fe commit 37d6b19

File tree

3 files changed

+314
-0
lines changed

3 files changed

+314
-0
lines changed

Cargo.lock

Lines changed: 11 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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "etc-merge"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
anyhow = { workspace = true }
8+
cap-std-ext = { workspace = true }
9+
rustix = { workspace = true }
10+
openssl = { workspace = true }
11+
hex = { workspace = true }
12+
13+
[lints]
14+
workspace = true

crates/etc-merge/src/lib.rs

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
//! Lib for /etc merge
2+
3+
#![allow(dead_code)]
4+
5+
use std::{collections::BTreeMap, io::Read, path::PathBuf};
6+
7+
use anyhow::Context;
8+
use cap_std_ext::cap_std;
9+
use cap_std_ext::cap_std::fs::{Dir as CapStdDir, MetadataExt, PermissionsExt};
10+
use openssl::hash::DigestBytes;
11+
use rustix::fs::readlinkat;
12+
13+
#[derive(Debug)]
14+
struct Metadata {
15+
content_hash: String,
16+
metadata_hash: String,
17+
}
18+
19+
impl Metadata {
20+
fn new(content_hash: String, metadata_hash: String) -> Self {
21+
Self {
22+
content_hash,
23+
metadata_hash,
24+
}
25+
}
26+
}
27+
28+
type Map = BTreeMap<PathBuf, Metadata>;
29+
30+
#[derive(Debug)]
31+
struct Diff {
32+
added: Vec<PathBuf>,
33+
modified: Vec<PathBuf>,
34+
removed: Vec<PathBuf>,
35+
}
36+
37+
// 1. Files in the currently booted deployment’s /etc which were modified from the default /usr/etc (of the same deployment) are retained.
38+
//
39+
// 2. Files in the currently booted deployment’s /etc which were not modified from the default /usr/etc (of the same deployment)
40+
// are upgraded to the new defaults from the new deployment’s /usr/etc.
41+
42+
// Modifications
43+
// 1. File deleted from new /etc
44+
// 2. File added in new /etc
45+
//
46+
// 3. File modified in new /etc
47+
// a. Content added/deleted
48+
// b. Permissions/ownership changed
49+
// c. Was a file but changed to directory/symlink etc or vice versa
50+
// d. xattrs changed - we don't include this right now
51+
52+
fn compute_diff(
53+
pristine_etc: &CapStdDir,
54+
current_etc: &CapStdDir,
55+
new_etc: &CapStdDir,
56+
) -> anyhow::Result<Diff> {
57+
let mut pristine_etc_files = BTreeMap::new();
58+
recurse_dir(pristine_etc, PathBuf::new(), &mut pristine_etc_files)
59+
.context(format!("Recursing {pristine_etc:?}"))?;
60+
61+
let mut current_etc_files = BTreeMap::new();
62+
recurse_dir(current_etc, PathBuf::new(), &mut current_etc_files)
63+
.context(format!("Recursing {current_etc:?}"))?;
64+
65+
let mut new_etc_files = BTreeMap::new();
66+
recurse_dir(new_etc, PathBuf::new(), &mut new_etc_files)
67+
.context(format!("Recursing {new_etc:?}"))?;
68+
69+
let mut added = vec![];
70+
let mut modified = vec![];
71+
72+
for (current_file, current_meta) in current_etc_files {
73+
let Some(old_meta) = pristine_etc_files.get(&current_file) else {
74+
// File was created
75+
added.push(current_file);
76+
continue;
77+
};
78+
79+
if old_meta.metadata_hash != current_meta.metadata_hash
80+
|| old_meta.content_hash != current_meta.content_hash
81+
{
82+
modified.push(current_file.clone());
83+
}
84+
85+
pristine_etc_files.remove(&current_file);
86+
}
87+
88+
let removed = pristine_etc_files.into_keys().collect::<Vec<PathBuf>>();
89+
90+
Ok(Diff {
91+
added,
92+
modified,
93+
removed,
94+
})
95+
}
96+
97+
fn compute_metadata_hash(meta: &cap_std::fs::Metadata) -> anyhow::Result<DigestBytes> {
98+
let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?;
99+
100+
let mut ty = vec![];
101+
102+
ty.push(meta.is_file() as u8);
103+
ty.push(meta.is_dir() as u8);
104+
ty.push(meta.is_symlink() as u8);
105+
106+
hasher.update(&ty)?;
107+
108+
if !meta.is_dir() {
109+
hasher.update(&meta.len().to_le_bytes())?;
110+
}
111+
112+
hasher.update(&meta.permissions().mode().to_le_bytes())?;
113+
114+
Ok(hasher.finish()?)
115+
}
116+
117+
fn recurse_dir(dir: &CapStdDir, mut path: PathBuf, list: &mut Map) -> anyhow::Result<()> {
118+
for entry in dir.entries()? {
119+
let entry = entry.context(format!("Getting entry for {path:?}"))?;
120+
let entry_name = entry.file_name();
121+
122+
path.push(&entry_name);
123+
124+
let entry_type = entry.file_type()?;
125+
126+
if entry_type.is_dir() {
127+
let dir = dir
128+
.open_dir(&entry_name)
129+
.with_context(|| format!("Opening dir {path:?} inside {dir:?}"))?;
130+
131+
let metadata = dir
132+
.metadata(".")
133+
.context(format!("Getting dir meta for {path:?}"))?;
134+
135+
list.insert(
136+
path.clone(),
137+
Metadata::new("".into(), hex::encode(compute_metadata_hash(&metadata)?)),
138+
);
139+
140+
recurse_dir(&dir, path.clone(), list).context(format!("Recursing {path:?}"))?;
141+
142+
path.pop();
143+
continue;
144+
}
145+
146+
let buf = if entry_type.is_symlink() {
147+
let readlinkat_result = readlinkat(&dir, &entry_name, vec![])
148+
.context(format!("readlinkat {entry_name:?}"))?;
149+
150+
readlinkat_result.into()
151+
} else if entry_type.is_file() {
152+
let mut buf = vec![0u8; entry.metadata()?.size() as usize];
153+
154+
let mut file = entry.open().context(format!("Opening entry {path:?}"))?;
155+
file.read_exact(&mut buf)
156+
.context(format!("Reading {path:?}. Buf: {buf:?}"))?;
157+
158+
buf
159+
} else {
160+
// We cannot read any other device like socket, pipe, fifo.
161+
// We shouldn't really find these in /etc in the first place
162+
unimplemented!("Found file of type {:?}", entry_type)
163+
};
164+
165+
let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?;
166+
hasher.update(&buf)?;
167+
let content_digest = hex::encode(hasher.finish()?);
168+
169+
let meta = entry
170+
.metadata()
171+
.context(format!("Getting metadata for {path:?}"))?;
172+
173+
list.insert(
174+
path.clone(),
175+
Metadata::new(content_digest, hex::encode(compute_metadata_hash(&meta)?)),
176+
);
177+
178+
path.pop();
179+
}
180+
181+
Ok(())
182+
}
183+
184+
#[cfg(test)]
185+
mod tests {
186+
use cap_std::fs::PermissionsExt;
187+
188+
use super::*;
189+
190+
const FILES: &[(&str, &str)] = &[
191+
("a/file1", "a-file1"),
192+
("a/file2", "a-file2"),
193+
("a/b/file1", "ab-file1"),
194+
("a/b/file2", "ab-file2"),
195+
("a/b/c/fileabc", "abc-file1"),
196+
("a/b/c/modify-perms", "modify-perms"),
197+
("a/b/c/to-be-removed", "remove this"),
198+
("to-be-removed", "remove this 2"),
199+
];
200+
201+
#[test]
202+
fn prepare_tmp_dirs() -> anyhow::Result<()> {
203+
let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
204+
205+
tempdir.create_dir("pristine_etc")?;
206+
tempdir.create_dir("current_etc")?;
207+
tempdir.create_dir("new_etc")?;
208+
209+
let p = tempdir.open_dir("pristine_etc")?;
210+
let c = tempdir.open_dir("current_etc")?;
211+
let n = tempdir.open_dir("new_etc")?;
212+
213+
p.create_dir_all("a")?;
214+
c.create_dir_all("a")?;
215+
p.create_dir_all("b")?;
216+
c.create_dir_all("b")?;
217+
218+
p.create_dir_all("a/b/c")?;
219+
c.create_dir_all("a/b/c")?;
220+
221+
let mut open_options = cap_std::fs::OpenOptions::new();
222+
open_options.create(true).write(true);
223+
224+
for (file, content) in FILES {
225+
p.write(file, content.as_bytes())?;
226+
c.write(file, content.as_bytes())?;
227+
}
228+
229+
let new_files = ["new_file", "a/new_file", "a/b/c/new_file"];
230+
231+
// Add some new files
232+
for file in new_files {
233+
c.write(file, b"hello")?;
234+
}
235+
236+
let overwritten_files = [FILES[1].0, FILES[4].0];
237+
let perm_changed_files = [FILES[5].0];
238+
239+
// Modify some files
240+
c.write(overwritten_files[0], b"some new content")?;
241+
c.write(overwritten_files[1], b"some newer content")?;
242+
243+
// Modify permissions
244+
let file = c.open(perm_changed_files[0])?;
245+
// This should be enough as the usual files have permission 644
246+
file.set_permissions(cap_std::fs::Permissions::from_mode(0o400))?;
247+
248+
// Remove some files
249+
let deleted_files = [FILES[6].0, FILES[7].0];
250+
c.remove_file(deleted_files[0])?;
251+
c.remove_file(deleted_files[1])?;
252+
253+
let res = compute_diff(&p, &c, &n)?;
254+
255+
// Test added files
256+
assert_eq!(res.added.len(), new_files.len());
257+
assert!(res.added.iter().all(|file| {
258+
new_files
259+
.iter()
260+
.find(|x| PathBuf::from(*x) == *file)
261+
.is_some()
262+
}));
263+
264+
// Test modified files
265+
let all_modified_files = overwritten_files
266+
.iter()
267+
.chain(&perm_changed_files)
268+
.collect::<Vec<_>>();
269+
270+
assert_eq!(res.modified.len(), all_modified_files.len());
271+
assert!(res.modified.iter().all(|file| {
272+
all_modified_files
273+
.iter()
274+
.find(|x| PathBuf::from(*x) == *file)
275+
.is_some()
276+
}));
277+
278+
// Test removed files
279+
assert_eq!(res.removed.len(), deleted_files.len());
280+
assert!(res.removed.iter().all(|file| {
281+
deleted_files
282+
.iter()
283+
.find(|x| PathBuf::from(*x) == *file)
284+
.is_some()
285+
}));
286+
287+
Ok(())
288+
}
289+
}

0 commit comments

Comments
 (0)