Fix: fstab-Setup - Mehrnutzer-sicheres Merging statt Komplett-Ersetzen

setup() ersetzte den gemeinsam genutzten verwalteten Block in
/etc/fstab bei jedem Lauf komplett anhand der aktuell geladenen
(nutzerspezifischen) Konfiguration. Führte ein zweiter Nutzer
`sudo smart-mount setup fstab` für sein eigenes Konto aus, wurden
dadurch die zuvor vom ersten Nutzer installierten Zeilen
stillschweigend gelöscht. write_managed_block() führt den Block jetzt
zeilenweise zusammen (jede Zeile trägt einen Tag-Kommentar mit
Paar-ID/Seite/Besitzer): eigene, jetzt gelöschte Paare werden
entfernt, Zeilen anderer Nutzer bleiben unangetastet.

Zusätzlich behoben:
- render_managed_block() brach beim ersten Paar, dessen Neuberechnung
  fehlschlug (z. B. ein gerade offline-MAC-adressiertes Gerät), die
  gesamte Blockerstellung ab - inklusive aller anderen, gesunden
  Paare. Fehlschläge werden jetzt pro Paar/Seite geloggt und
  übersprungen, die bestehende Zeile bleibt in diesem Fall erhalten.
- /etc/fstab wurde per direktem std::fs::write (Trunkieren) statt
  atomar geschrieben - ein Absturz mitten im Schreiben konnte die
  Datei in einem leeren/kaputten Zustand zurücklassen. Alle Schreib-
  zugriffe (setup, teardown) laufen jetzt über Temp-Datei + rename.
- user_home_dir() erriet bei fehlgeschlagener getent/passwd-Auflösung
  stillschweigend "/home/<user>" - loggt jetzt eine Warnung.

util.rs bekommt dafür extract_managed_block() als Gegenstück zu
strip_managed_block().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
This commit is contained in:
2026-09-15 23:05:52 +02:00
co-authored by Claude Sonnet 5
parent efabfdf7b1
commit bb9290c113
2 changed files with 327 additions and 14 deletions
+280 -14
View File
@@ -11,7 +11,8 @@
//! zur Laufzeit zwischen den beiden Backing-Verzeichnissen umschaltet (siehe
//! [`crate::reconcile`]).
use std::path::PathBuf;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{AppConfig, DrivePair, GlobalSettings, MountContext, MountKind};
@@ -38,6 +39,17 @@ pub fn setup() -> Result<()> {
)));
}
let sudo_user = std::env::var("SUDO_USER")
.ok()
.filter(|s| !s.is_empty());
if let Some(sudo_user) = &sudo_user
&& config_ctdra::get_custom_path().is_none()
{
let user_config = user_config_path(sudo_user);
config_ctdra::set_custom_path(&user_config);
}
let cfg = crate::config::pairs::load()?;
let user_pairs: Vec<&DrivePair> = cfg
.pairs
@@ -57,7 +69,24 @@ pub fn setup() -> Result<()> {
ensure_group_membership(pair)?;
}
write_managed_block(&user_pairs, &cfg.settings)?;
// Wessen zuvor installierte, jetzt aber nicht mehr konfigurierte Zeilen beim
// Zusammenführen (siehe `merge_managed_block`) entfernt werden dürfen: bei einem
// `sudo`-Aufruf im Namen eines bestimmten Nutzers ausschließlich dessen eigene Zeilen,
// sonst die Menge der in der (dann root-eigenen) Konfiguration genannten `owner_user`.
// Zeilen ANDERER Nutzer bleiben immer unangetastet - andernfalls würde ein zweiter Nutzer,
// der `setup fstab` für sein eigenes Konto ausführt, die vom ersten Nutzer installierten
// Zeilen löschen, weil sich beide denselben verwalteten Block in `/etc/fstab` teilen.
let owner_scope: Vec<String> = match &sudo_user {
Some(u) => vec![u.clone()],
None => user_pairs
.iter()
.filter_map(|p| p.owner_user.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect(),
};
write_managed_block(&user_pairs, &cfg.settings, &owner_scope)?;
logger_ctdra::info(
"fstab",
@@ -103,7 +132,7 @@ pub fn teardown() -> Result<FstabTeardownOutcome> {
backup(&fstab_path, &existing)?;
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
std::fs::write(&fstab_path, without_block).map_err(|e| Error::io(&fstab_path, e))?;
write_atomic(&fstab_path, &without_block)?;
Ok(FstabTeardownOutcome::Removed)
}
@@ -205,23 +234,29 @@ fn ensure_group_membership(pair: &DrivePair) -> Result<()> {
Ok(())
}
fn write_managed_block(pairs: &[&DrivePair], settings: &GlobalSettings) -> Result<()> {
fn write_managed_block(
pairs: &[&DrivePair],
settings: &GlobalSettings,
owner_scope: &[String],
) -> Result<()> {
let fstab_path = PathBuf::from(FSTAB_PATH);
let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default();
backup(&fstab_path, &existing)?;
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
let block = render_managed_block(pairs, settings)?;
let current_block =
crate::util::extract_managed_block(&existing, BEGIN_MARKER, END_MARKER).unwrap_or_default();
let new_block = merge_managed_block(&current_block, pairs, settings, owner_scope);
let new_contents = format!(
"{}\n{}\n{}\n{}\n",
without_block.trim_end(),
BEGIN_MARKER,
block.trim_end(),
new_block.trim_end(),
END_MARKER
);
std::fs::write(&fstab_path, new_contents).map_err(|e| Error::io(&fstab_path, e))
write_atomic(&fstab_path, &new_contents)
}
fn backup(fstab_path: &PathBuf, contents: &str) -> Result<()> {
@@ -231,13 +266,134 @@ fn backup(fstab_path: &PathBuf, contents: &str) -> Result<()> {
Ok(())
}
fn render_managed_block(pairs: &[&DrivePair], settings: &GlobalSettings) -> Result<String> {
let mut lines = Vec::new();
for pair in pairs {
lines.push(fstab_line(pair, Side::Local, settings)?);
lines.push(fstab_line(pair, Side::Cloud, settings)?);
/// Schreibt `contents` atomar (Temp-Datei im selben Verzeichnis + `rename`) statt per direktem
/// Trunkieren-und-Schreiben - ein Absturz oder ein volles Dateisystem mitten im Schreiben
/// könnte `/etc/fstab` sonst in einem leeren/halb geschriebenen Zustand zurücklassen, was den
/// nächsten Boot verhindern kann.
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
let tmp_path = PathBuf::from(format!("{}.smart-mount-tmp", path.display()));
std::fs::write(&tmp_path, contents).map_err(|e| Error::io(&tmp_path, e))?;
std::fs::rename(&tmp_path, path).map_err(|e| Error::io(path, e))
}
fn side_str(side: Side) -> &'static str {
match side {
Side::Local => "local",
Side::Cloud => "cloud",
}
Ok(lines.join("\n"))
}
/// Tag-Kommentar, der an jede von smart-mount geschriebene fstab-Zeile angehängt wird
/// (`man 5 fstab`: ein `#` leitet einen bis zum Zeilenende reichenden Kommentar ein, auch nach
/// den 6 regulären Feldern - das stört `mount(8)` nicht). Erlaubt, beim nächsten `setup fstab`
/// zeilenweise zu erkennen, zu welchem Paar/welcher Seite/welchem Besitzer eine bestehende
/// Zeile gehört, statt den kompletten Block bei jedem Lauf zu ersetzen (siehe
/// [`merge_managed_block`]).
fn line_tag(pair: &DrivePair, side: Side) -> String {
format!(
"smart-mount pair={} side={} owner={}",
pair.id,
side_str(side),
pair.owner_user.as_deref().unwrap_or("-")
)
}
fn tagged_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result<String> {
let line = fstab_line(pair, side, settings)?;
Ok(format!("{line} # {}", line_tag(pair, side)))
}
/// Liest `(pair_id, side, owner)` aus dem von [`line_tag`] angehängten Kommentar einer
/// bestehenden fstab-Zeile, falls vorhanden.
fn parse_tag(line: &str) -> Option<(String, &'static str, String)> {
let marker = "# smart-mount ";
let idx = line.find(marker)?;
let rest = &line[idx + marker.len()..];
let mut pair_id = None;
let mut side = None;
let mut owner = None;
for token in rest.split_whitespace() {
if let Some(v) = token.strip_prefix("pair=") {
pair_id = Some(v.to_string());
} else if let Some(v) = token.strip_prefix("side=") {
side = match v {
"local" => Some("local"),
"cloud" => Some("cloud"),
_ => None,
};
} else if let Some(v) = token.strip_prefix("owner=") {
owner = Some(v.to_string());
}
}
Some((pair_id?, side?, owner.unwrap_or_else(|| "-".to_string())))
}
/// Führt den bestehenden verwalteten Block mit den frisch berechneten Zeilen für `pairs`
/// zusammen, statt ihn komplett zu ersetzen:
/// - eine bestehende Zeile, die zu einem der aktuell verarbeiteten Paare gehört, wird durch die
/// frische Version ersetzt (oder, falls deren Neuberechnung fehlschlägt, z. B. weil ein
/// MAC-adressiertes lokales Gerät gerade offline ist, unverändert beibehalten statt
/// ersatzlos gelöscht - siehe [`fstab_line`]/[`tagged_line`]);
/// - eine Zeile eines inzwischen aus der Konfiguration entfernten Paares DESSELBEN Nutzers
/// (`owner_scope`) wird entfernt;
/// - jede andere Zeile (insbesondere die eines ANDEREN Nutzers) bleibt unangetastet.
///
/// Ohne diese Unterscheidung würde ein zweiter Nutzer, der `setup fstab` für sein eigenes Konto
/// ausführt, versehentlich die vom ersten Nutzer installierten Zeilen löschen, da beide
/// denselben verwalteten Block in `/etc/fstab` teilen. Ebenso würde ein einzelnes Paar, dessen
/// Neuberechnung gerade fehlschlägt, sonst den gesamten Block-Rebuild für alle anderen,
/// gesunden Paare verhindern.
fn merge_managed_block(
current_block: &str,
pairs: &[&DrivePair],
settings: &GlobalSettings,
owner_scope: &[String],
) -> String {
let current_pair_ids: HashSet<&str> = pairs.iter().map(|p| p.id.as_str()).collect();
let mut new_lines = Vec::new();
let mut replaced: HashSet<(String, &'static str)> = HashSet::new();
for pair in pairs {
for side in [Side::Local, Side::Cloud] {
match tagged_line(pair, side, settings) {
Ok(line) => {
replaced.insert((pair.id.clone(), side_str(side)));
new_lines.push(line);
}
Err(e) => {
logger_ctdra::warn(
"fstab",
&format!(
"could not compute fstab entry for pair '{}' ({}): {e} - leaving \
any existing entry for it untouched",
pair.id,
side_str(side)
),
);
}
}
}
}
let mut kept: Vec<String> = current_block
.lines()
.filter(|line| match parse_tag(line) {
Some((pair_id, side, _)) if replaced.contains(&(pair_id.clone(), side)) => false,
Some((pair_id, _, owner))
if !current_pair_ids.contains(pair_id.as_str())
&& owner_scope.iter().any(|o| o == &owner) =>
{
false
}
_ => true,
})
.map(str::to_string)
.collect();
kept.extend(new_lines);
kept.join("\n")
}
fn fstab_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result<String> {
@@ -288,6 +444,53 @@ fn fstab_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result
))
}
fn user_config_path(username: &str) -> PathBuf {
let program_name = config_ctdra::get_program_name();
let config_name = config_ctdra::get_config_name();
let file_name = if config_name.ends_with(".toml") {
config_name
} else {
format!("{config_name}.toml")
};
user_home_dir(username)
.map(|h| h.join(".config").join(&program_name).join(&file_name))
.unwrap_or_else(|| {
PathBuf::from(format!("/home/{username}/.config/{program_name}/{file_name}"))
})
}
fn user_home_dir(username: &str) -> Option<PathBuf> {
if let Ok(output) = Command::new("getent").args(["passwd", username]).output()
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
let fields: Vec<&str> = stdout.trim().split(':').collect();
if fields.len() >= 6 && !fields[5].is_empty() {
return Some(PathBuf::from(fields[5]));
}
}
if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
for line in passwd.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 6 && fields[0] == username && !fields[5].is_empty() {
return Some(PathBuf::from(fields[5]));
}
}
}
// Weder `getent` noch `/etc/passwd` konnten den Nutzer auflösen - das reine Erraten von
// `/home/<user>` kann bei einem abweichenden Home-Verzeichnis (oder falsch geschriebenem
// Nutzernamen) dazu führen, dass `setup()` anschließend die Konfigurationsdatei am
// falschen Pfad lädt und stillschweigend "nichts zu tun" meldet. Warnen statt schweigen.
logger_ctdra::warn(
"fstab",
&format!(
"could not resolve home directory for user '{username}' via getent/passwd - \
guessing '/home/{username}'"
),
);
Some(PathBuf::from(format!("/home/{username}")))
}
/// Zeigt an, dass diese Konfiguration bereits ein einmaliges `setup fstab` benötigt hat.
pub fn requires_setup(cfg: &AppConfig) -> bool {
cfg.pairs.iter().any(|p| p.context == MountContext::User)
@@ -345,7 +548,7 @@ mod tests {
#[test]
fn renders_two_lines_per_pair_each_with_its_own_unique_target() {
let pair = sample_pair();
let block = render_managed_block(&[&pair], &GlobalSettings::default()).expect("render");
let block = merge_managed_block("", &[&pair], &GlobalSettings::default(), &[]);
let lines: Vec<&str> = block.lines().collect();
assert_eq!(lines.len(), 2);
@@ -377,6 +580,60 @@ mod tests {
);
}
#[test]
fn merge_managed_block_preserves_lines_belonging_to_other_users() {
let pair = sample_pair();
let other_users_line = "//other/share /backing/other cifs user,exec,noauto 0 0 \
# smart-mount pair=other-pair side=local owner=someone-else";
let block = merge_managed_block(
other_users_line,
&[&pair],
&GlobalSettings::default(),
&[pair.owner_user.clone().unwrap()],
);
assert!(
block.contains(other_users_line),
"a run scoped to one user must not touch another user's fstab lines"
);
assert!(block.contains("pair=pair-1"));
}
#[test]
fn merge_managed_block_drops_stale_lines_for_a_removed_pair_of_the_same_owner() {
let pair = sample_pair();
let owner = pair.owner_user.clone().unwrap();
let stale_line = format!(
"//old/share /backing/old cifs user,exec,noauto 0 0 \
# smart-mount pair=deleted-pair side=local owner={owner}"
);
// `deleted-pair` is no longer part of `pairs`, so its line should be dropped since it
// belongs to the same owner this run is scoped to - but only then.
let block = merge_managed_block(&stale_line, &[&pair], &GlobalSettings::default(), &[
owner,
]);
assert!(!block.contains("deleted-pair"));
assert!(block.contains("pair=pair-1"));
}
#[test]
fn merge_managed_block_keeps_stale_lines_of_a_different_owner() {
let pair = sample_pair();
let stale_line = "//old/share /backing/old cifs user,exec,noauto 0 0 \
# smart-mount pair=deleted-pair side=local owner=someone-else";
// `owner_scope` only covers `pair.owner_user`, not `someone-else` - the stale line must
// survive even though its pair is absent from `pairs`.
let block = merge_managed_block(
stale_line,
&[&pair],
&GlobalSettings::default(),
&[pair.owner_user.clone().unwrap()],
);
assert!(block.contains("deleted-pair"));
}
#[test]
fn strip_managed_block_removes_only_the_marked_section() {
let contents = "/dev/sda1 / ext4 defaults 0 1\n# BEGIN smart-mount managed block\nfoo\n# END smart-mount managed block\n";
@@ -422,4 +679,13 @@ mod tests {
create_dir_all_owned(&target, None).expect("create_dir_all_owned");
assert!(target.is_dir());
}
#[test]
fn user_config_path_resolves_for_user() {
let user = std::env::var("USER").expect("USER env var set in test environment");
let path = user_config_path(&user);
let s = path.to_str().unwrap();
assert!(s.contains(&format!("/home/{user}/.config/")));
assert!(s.ends_with("/config.toml"));
}
}
+47
View File
@@ -25,6 +25,35 @@ pub(crate) fn strip_managed_block(contents: &str, begin_marker: &str, end_marker
out
}
/// Gegenstück zu [`strip_managed_block`]: gibt nur den Inhalt *innerhalb* des Blocks zurück
/// (ohne die Marker-Zeilen selbst), oder `None`, falls kein solcher Block vorhanden ist. Für
/// ein zeilenweises Zusammenführen (statt komplettem Ersetzen) des verwalteten Blocks.
pub(crate) fn extract_managed_block(
contents: &str,
begin_marker: &str,
end_marker: &str,
) -> Option<String> {
let mut out = String::new();
let mut inside = false;
let mut found = false;
for line in contents.lines() {
if line.trim() == begin_marker {
inside = true;
found = true;
continue;
}
if line.trim() == end_marker {
inside = false;
continue;
}
if inside {
out.push_str(line);
out.push('\n');
}
}
found.then_some(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -53,4 +82,22 @@ mod tests {
"keep-me\n"
);
}
#[test]
fn extract_managed_block_returns_only_the_interior() {
let contents = "line1\n# BEGIN test\nfoo\nbar\n# END test\nline2\n";
assert_eq!(
extract_managed_block(contents, "# BEGIN test", "# END test"),
Some("foo\nbar\n".to_string())
);
}
#[test]
fn extract_managed_block_is_none_when_markers_are_absent() {
let contents = "line1\nline2\n";
assert_eq!(
extract_managed_block(contents, "# BEGIN test", "# END test"),
None
);
}
}