Skip to content

Commit ede5599

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

File tree

3 files changed

+306
-0
lines changed

3 files changed

+306
-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: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
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};
10+
use openssl::hash::DigestBytes;
11+
use rustix::fs::readlinkat;
12+
13+
#[derive(Debug)]
14+
struct Metadata {
15+
content_hash: String,
16+
metdata_hash: String,
17+
}
18+
19+
impl Metadata {
20+
fn new(content_hash: String, metdata_hash: String) -> Self {
21+
Self {
22+
content_hash,
23+
metdata_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.metdata_hash != current_meta.metdata_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+
Ok(hasher.finish()?)
113+
}
114+
115+
fn recurse_dir(dir: &CapStdDir, mut path: PathBuf, list: &mut Map) -> anyhow::Result<()> {
116+
for entry in dir.entries()? {
117+
let entry = entry.context(format!("Getting entry for {path:?}"))?;
118+
let entry_name = entry.file_name();
119+
120+
path.push(&entry_name);
121+
122+
let entry_type = entry.file_type()?;
123+
124+
if entry_type.is_dir() {
125+
let dir = dir
126+
.open_dir(&entry_name)
127+
.with_context(|| format!("Opening dir {path:?} inside {dir:?}"))?;
128+
129+
let metadata = dir
130+
.metadata(".")
131+
.context(format!("Getting dir meta for {path:?}"))?;
132+
133+
list.insert(
134+
path.clone(),
135+
Metadata::new("".into(), hex::encode(compute_metadata_hash(&metadata)?)),
136+
);
137+
138+
recurse_dir(&dir, path.clone(), list).context(format!("Recursing {path:?}"))?;
139+
140+
path.pop();
141+
continue;
142+
}
143+
144+
let buf = if entry_type.is_symlink() {
145+
let readlinkat_result = readlinkat(&dir, &entry_name, vec![])
146+
.context(format!("readlinkat {entry_name:?}"))?;
147+
148+
readlinkat_result.into()
149+
} else if entry_type.is_file() {
150+
let mut buf = vec![0u8; entry.metadata()?.size() as usize];
151+
152+
let mut file = entry.open().context(format!("Opening entry {path:?}"))?;
153+
file.read_exact(&mut buf)
154+
.context(format!("Reading {path:?}. Buf: {buf:?}"))?;
155+
156+
buf
157+
} else {
158+
// We cannot read any other device like socket, pipe, fifo.
159+
// We shouldn't really find these in /etc in the first place
160+
unimplemented!("Found file of type {:?}", entry_type)
161+
};
162+
163+
let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?;
164+
hasher.update(&buf)?;
165+
let content_digest = hex::encode(hasher.finish()?);
166+
167+
let meta = entry
168+
.metadata()
169+
.context(format!("Getting metadata for {path:?}"))?;
170+
171+
list.insert(
172+
path.clone(),
173+
Metadata::new(content_digest, hex::encode(compute_metadata_hash(&meta)?)),
174+
);
175+
176+
path.pop();
177+
}
178+
179+
Ok(())
180+
}
181+
182+
#[cfg(test)]
183+
mod tests {
184+
use cap_std::fs::PermissionsExt;
185+
186+
use super::*;
187+
188+
const FILES: &[(&str, &str)] = &[
189+
("a/file1", "a-file1"),
190+
("a/file2", "a-file2"),
191+
("a/b/file1", "ab-file1"),
192+
("a/b/file1", "ab-file1"),
193+
("a/b/c/fileabc", "abc-file1"),
194+
("a/b/c/modify-perms", "modify-perms"),
195+
("a/b/c/to-be-removed", "remove this"),
196+
("to-be-removed", "remove this 2"),
197+
];
198+
199+
#[test]
200+
fn prepare_tmp_dirs() -> anyhow::Result<()> {
201+
let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
202+
203+
tempdir.create_dir("pristine_etc")?;
204+
tempdir.create_dir("current_etc")?;
205+
tempdir.create_dir("new_etc")?;
206+
207+
let p = tempdir.open_dir("pristine_etc")?;
208+
let c = tempdir.open_dir("current_etc")?;
209+
let n = tempdir.open_dir("new_etc")?;
210+
211+
p.create_dir_all("a")?;
212+
c.create_dir_all("a")?;
213+
p.create_dir_all("b")?;
214+
c.create_dir_all("b")?;
215+
216+
p.create_dir_all("a/b/c")?;
217+
c.create_dir_all("a/b/c")?;
218+
219+
let mut open_options = cap_std::fs::OpenOptions::new();
220+
open_options.create(true).write(true);
221+
222+
for (file, content) in FILES {
223+
p.write(file, content.as_bytes())?;
224+
c.write(file, content.as_bytes())?;
225+
}
226+
227+
let new_files = ["new_file", "a/new_file", "a/b/c/new_file"];
228+
229+
// Add some new files
230+
for file in new_files {
231+
c.write(file, b"hello")?;
232+
}
233+
234+
let overwritten_files = [FILES[1].0, FILES[4].0];
235+
let perm_changed_files = [FILES[5].0];
236+
237+
// Modify some files
238+
c.write(overwritten_files[0], b"some new content")?;
239+
c.write(overwritten_files[1], b"some newer content")?;
240+
241+
// Modify permissions
242+
let file = c.open(perm_changed_files[0])?;
243+
// This should be enough as the usual files have permission 644
244+
file.set_permissions(cap_std::fs::Permissions::from_mode(0o400))?;
245+
246+
// Remove some files
247+
let deleted_files = [FILES[6].0, FILES[7].0];
248+
c.remove_file(deleted_files[0])?;
249+
c.remove_file(deleted_files[1])?;
250+
251+
let res = compute_diff(&p, &c, &n)?;
252+
253+
// Test added files
254+
assert_eq!(res.added.len(), new_files.len());
255+
assert!(res.added.iter().all(|file| new_files
256+
.iter()
257+
.find(|x| PathBuf::from(*x) == *file)
258+
.is_some()));
259+
260+
// Test modified files
261+
let all_modified_files = overwritten_files
262+
.iter()
263+
.chain(&perm_changed_files)
264+
.collect::<Vec<_>>();
265+
266+
assert_eq!(res.modified.len(), all_modified_files.len());
267+
assert!(res.modified.iter().all(|file| all_modified_files
268+
.iter()
269+
.find(|x| PathBuf::from(*x) == *file)
270+
.is_some()));
271+
272+
// Test removed files
273+
assert_eq!(res.removed.len(), deleted_files.len());
274+
assert!(res.removed.iter().all(|file| deleted_files
275+
.iter()
276+
.find(|x| PathBuf::from(*x) == *file)
277+
.is_some()));
278+
279+
Ok(())
280+
}
281+
}

0 commit comments

Comments
 (0)