Fix: davfs2-secrets-Datei - Quoting und exaktes URL-Matching

write_secrets_entry()/remove_secrets_entry() identifizierten eine
Zeile per l.trim_start().starts_with(url) - eine URL, die textlich
Präfix einer anderen ist (z. B. .../dav vs. .../dav-archive), traf
dadurch auf beide Zeilen und konnte das falsche Paar korrumpieren
oder dessen Zugangsdaten löschen. Matching erfolgt jetzt exakt gegen
das erste Feld der Zeile.

Passwörter/Nutzernamen mit Leerraum wurden zudem unquotiert
geschrieben - davfs2 trennt Felder per Leerraum und hätte ein
Passwort mit Leerzeichen am ersten Leerzeichen abgeschnitten. Werte
mit Leerraum werden jetzt in Anführungszeichen gefasst (siehe
man davfs2.conf). Leere Nutzername/Passwort-Felder werden zusätzlich
vor dem Schreiben abgelehnt statt eine kaputte Zeile zu erzeugen.

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:38 +02:00
co-authored by Claude Sonnet 5
parent 706494d106
commit efabfdf7b1
+82 -3
View File
@@ -43,6 +43,11 @@ impl MountBackend for WebDavBackend {
if let Some(cred) = cred
&& let Some(username) = &cred.username
{
if username.is_empty() || cred.password.is_empty() {
return Err(Error::Other(
"davfs2 credential has an empty username or password".to_string(),
));
}
write_secrets_entry(
&davfs2_secrets_path(),
&target.source,
@@ -154,16 +159,43 @@ fn ensure_config_line(path: &Path, key: &str, value: &str, comment: &str) -> Res
fs::write(path, format!("{}\n", new_lines.join("\n"))).map_err(|e| Error::io(path, e))
}
/// Erste (durch Leerraum getrennte oder in Anführungszeichen gefasste) Spalte einer
/// `secrets`-Zeile - die URL, gegen die eine Zeile identifiziert wird.
fn secrets_entry_url(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix('"') {
return rest.split('"').next();
}
trimmed.split_whitespace().next()
}
/// davfs2 trennt Felder in der `secrets`-Datei per Leerraum; enthält ein Wert Leerraum, muss
/// er nach `man davfs2.conf` in doppelte Anführungszeichen gefasst werden. Ohne diese
/// Behandlung würde davfs2 ein Passwort mit Leerzeichen stillschweigend am ersten Leerzeichen
/// abschneiden.
fn quote_secrets_field(value: &str) -> String {
if value.chars().any(char::is_whitespace) {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_string()
}
}
/// Schreibt/aktualisiert eine Zeile in davfs2s `secrets`-Datei (`<url> <username> <password>`,
/// muss chmod 600 sein). Ersetzt eine bestehende Zeile für dieselbe URL statt sie zu duplizieren.
fn write_secrets_entry(path: &PathBuf, url: &str, username: &str, password: &str) -> Result<()> {
let existing = fs::read_to_string(path).unwrap_or_default();
let mut lines: Vec<String> = existing
.lines()
.filter(|l| !l.trim_start().starts_with(url))
.filter(|l| secrets_entry_url(l) != Some(url))
.map(str::to_string)
.collect();
lines.push(format!("{url} {username} {password}"));
lines.push(format!(
"{} {} {}",
quote_secrets_field(url),
quote_secrets_field(username),
quote_secrets_field(password)
));
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
@@ -213,7 +245,7 @@ pub(crate) fn remove_secrets_entry(path: &Path, url: &str) -> Result<()> {
};
let remaining: Vec<&str> = existing
.lines()
.filter(|l| !l.trim_start().starts_with(url))
.filter(|l| secrets_entry_url(l) != Some(url))
.collect();
if remaining.len() == existing.lines().count() {
return Ok(());
@@ -325,6 +357,34 @@ mod tests {
assert!(!contents.contains("old-pass"));
}
#[test]
fn write_secrets_entry_quotes_a_password_containing_whitespace() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets");
write_secrets_entry(&path, "https://cloud/dav", "user", "my pass word").expect("write");
let contents = fs::read_to_string(&path).expect("read");
assert!(contents.contains("\"my pass word\""));
}
#[test]
fn write_secrets_entry_does_not_clobber_a_url_that_is_a_prefix_of_another() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets");
write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1").expect("write 1");
write_secrets_entry(&path, "http://192.168.1.5/dav-archive", "user2", "pass2")
.expect("write 2");
write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1-updated")
.expect("write 3");
let contents = fs::read_to_string(&path).expect("read");
assert!(contents.contains("pass1-updated"));
assert!(!contents.contains("pass1\n") && !contents.ends_with("pass1"));
assert!(contents.contains("pass2"), "unrelated prefix-matching URL's entry must survive");
}
#[test]
fn remove_secrets_entry_deletes_only_the_matching_url() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -342,6 +402,25 @@ mod tests {
assert!(contents.contains("pass2"));
}
#[test]
fn remove_secrets_entry_does_not_remove_a_url_that_the_target_url_is_a_prefix_of() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets");
write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1").expect("write 1");
write_secrets_entry(&path, "http://192.168.1.5/dav-archive", "user2", "pass2")
.expect("write 2");
remove_secrets_entry(&path, "http://192.168.1.5/dav").expect("remove");
let contents = fs::read_to_string(&path).expect("read");
assert!(!contents.contains("pass1"));
assert!(
contents.contains("http://192.168.1.5/dav-archive") && contents.contains("pass2"),
"removing the shorter URL must not delete the longer URL's entry"
);
}
#[test]
fn remove_secrets_entry_is_a_noop_for_missing_file_or_unmatched_url() {
let dir = tempfile::tempdir().expect("tempdir");