//! Credential lookup in lftp bookmark files. //! //! lftp keeps bookmarks as `nameURL` lines, and (when //! `bmk:save-passwords` is enabled) those URLs carry percent-encoded //! passwords. Since that is exactly where FTP/SFTP credentials tend to //! already live, the FTP and SFTP backends consult it when a repository //! URL does not spell out its own authentication. //! //! The file is searched at `$LFTP_HOME/bookmarks`, then //! `~/.lftp/bookmarks`, then the XDG location //! `~/.local/share/lftp/bookmarks` — the same order lftp itself uses. //! //! A bookmark matches when its scheme, host, and effective port equal //! the target's, and — if the target URL names a user — its user //! agrees. Among matches, the first one carrying a password wins. use std::path::PathBuf; use url::Url; use crate::percent_decode; pub(crate) struct Credentials { pub(crate) user: String, pub(crate) password: Option, } /// Finds bookmark credentials for a target server, or `None` when no /// bookmark file exists or nothing matches. pub(crate) fn find_credentials( scheme: &str, host: &str, port: u16, user: Option<&str>, ) -> Option { let text = std::fs::read_to_string(bookmarks_file()?).ok()?; find_in(&text, scheme, host, port, user) } /// Looks up a bookmark by name, returning its raw location text. pub(crate) fn bookmark_location(name: &str) -> Option { let text = std::fs::read_to_string(bookmarks_file()?).ok()?; location_in(&text, name) } fn location_in(text: &str, name: &str) -> Option { for line in text.lines() { if let Some((candidate, location)) = line.split_once(char::is_whitespace) && candidate == name { return Some(location.trim().to_string()); } } None } fn bookmarks_file() -> Option { if let Some(home) = std::env::var_os("LFTP_HOME") { return Some(PathBuf::from(home).join("bookmarks")); } let classic = dirs::home_dir()?.join(".lftp/bookmarks"); if classic.exists() { return Some(classic); } Some(dirs::data_dir()?.join("lftp/bookmarks")) } fn find_in( text: &str, scheme: &str, host: &str, port: u16, user: Option<&str>, ) -> Option { let mut passwordless = None; for line in text.lines() { let Some((_name, location)) = line.split_once(char::is_whitespace) else { continue; }; let Ok(url) = Url::parse(location.trim()) else { continue; }; if !scheme_matches(url.scheme(), scheme) || !url .host_str() .is_some_and(|candidate| candidate.eq_ignore_ascii_case(host)) || effective_port(&url) != port || url.username().is_empty() { continue; } let bookmark_user = percent_decode(url.username()); if let Some(required) = user && required != bookmark_user { continue; } let credentials = Credentials { user: bookmark_user, password: url.password().map(percent_decode), }; if credentials.password.is_some() { return Some(credentials); } passwordless = passwordless.or(Some(credentials)); } passwordless } /// lftp and beeping both accept `ssh` as a synonym for `sftp`. fn scheme_matches(bookmark: &str, target: &str) -> bool { normalize_scheme(bookmark) == normalize_scheme(target) } fn normalize_scheme(scheme: &str) -> &str { match scheme { "ssh" => "sftp", other => other, } } fn effective_port(url: &Url) -> u16 { url.port().unwrap_or(match normalize_scheme(url.scheme()) { "ftp" => 21, "sftp" => 22, _ => 0, }) } #[cfg(test)] mod tests { use super::*; const BOOKMARKS: &str = "\ work\tftp://alice:s3cret@files.example.com/upload spaced ftp://bob:p%40ss%20word@other.example.com:2121/ nopass\tftp://carol@plain.example.com/ shell\tsftp://dave:hunter2@shell.example.com/home/dave sshstyle\tssh://erin:swordfish@alt.example.com/ broken line without a url plainpath\t/some/local/path "; #[test] fn matches_scheme_host_and_port() { let found = find_in(BOOKMARKS, "ftp", "files.example.com", 21, None).unwrap(); assert_eq!(found.user, "alice"); assert_eq!(found.password.as_deref(), Some("s3cret")); assert!(find_in(BOOKMARKS, "ftp", "files.example.com", 2121, None).is_none()); assert!(find_in(BOOKMARKS, "sftp", "files.example.com", 22, None).is_none()); assert!(find_in(BOOKMARKS, "ftp", "unknown.example.com", 21, None).is_none()); } #[test] fn decodes_percent_encoded_passwords() { let found = find_in(BOOKMARKS, "ftp", "other.example.com", 2121, None).unwrap(); assert_eq!(found.user, "bob"); assert_eq!(found.password.as_deref(), Some("p@ss word")); } #[test] fn respects_a_required_user() { assert!(find_in(BOOKMARKS, "ftp", "files.example.com", 21, Some("mallory")).is_none()); let found = find_in(BOOKMARKS, "ftp", "files.example.com", 21, Some("alice")).unwrap(); assert_eq!(found.password.as_deref(), Some("s3cret")); } #[test] fn passwordless_bookmarks_still_supply_the_user() { let found = find_in(BOOKMARKS, "ftp", "plain.example.com", 21, None).unwrap(); assert_eq!(found.user, "carol"); assert_eq!(found.password, None); } #[test] fn bookmarks_resolve_by_name() { assert_eq!( location_in(BOOKMARKS, "work").as_deref(), Some("ftp://alice:s3cret@files.example.com/upload") ); assert_eq!( location_in(BOOKMARKS, "spaced").as_deref(), Some("ftp://bob:p%40ss%20word@other.example.com:2121/"), "space-separated bookmark lines are valid too" ); assert_eq!( location_in(BOOKMARKS, "plainpath").as_deref(), Some("/some/local/path") ); assert_eq!(location_in(BOOKMARKS, "no-such-bookmark"), None); assert_eq!( location_in(BOOKMARKS, "broken"), Some("line without a url".to_string()), "name resolution is textual; URL validation is the caller's" ); } #[test] fn ssh_and_sftp_are_synonyms() { let found = find_in(BOOKMARKS, "sftp", "alt.example.com", 22, None).unwrap(); assert_eq!(found.user, "erin"); assert_eq!(found.password.as_deref(), Some("swordfish")); let found = find_in(BOOKMARKS, "ssh", "shell.example.com", 22, None).unwrap(); assert_eq!(found.user, "dave"); } }