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:
+82
-3
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user