diff --git a/src/mount/webdav.rs b/src/mount/webdav.rs index e9c47fc..816d70f 100644 --- a/src/mount/webdav.rs +++ b/src/mount/webdav.rs @@ -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 (` `, /// 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 = 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");