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");
    }
}