lftp.rs
raw
//! Credential lookup in lftp bookmark files.
//!
//! lftp keeps bookmarks as `name<whitespace>URL` 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<String>,
}
/// 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<Credentials> {
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<String> {
let text = std::fs::read_to_string(bookmarks_file()?).ok()?;
location_in(&text, name)
}
fn location_in(text: &str, name: &str) -> Option<String> {
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<PathBuf> {
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<Credentials> {
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");
}
}