use std::collections::HashMap;
use inquire::InquireError;
const CREATE_NEW: &str = "[+ Create new profile]";
macro_rules! ask {
($expr:expr) => {
match $expr {
Ok(v) => v,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(None)
}
Err(e) => return Err(e.to_string()),
}
};
}
#[derive(Debug, PartialEq)]
pub enum CredentialType {
B2,
S3,
Generic,
}
pub fn detect_url_type(url: &str) -> CredentialType {
if url.starts_with("b2:") {
CredentialType::B2
} else if url.starts_with("s3:") || url.starts_with("s3+https:") || url.starts_with("rustfs:") {
CredentialType::S3
} else {
CredentialType::Generic
}
}
pub fn suggest_profile_name(url: &str) -> String {
let scheme_end = url.find(':').unwrap_or(url.len());
let scheme = &url[..scheme_end];
let after_colon = url.get(scheme_end + 1..).unwrap_or("");
if !after_colon.starts_with("//") && !after_colon.contains("://") {
return scheme.to_string();
}
let after_slashes = if let Some(pos) = after_colon.find("//") {
&after_colon[pos + 2..]
} else {
after_colon
};
let host_with_port = after_slashes.split('/').next().unwrap_or(after_slashes);
let host = host_with_port.split(':').next().unwrap_or(host_with_port);
if host.chars().all(|c| c.is_ascii_digit() || c == '.') {
return host.to_string();
}
host.split('.').next().unwrap_or(host).to_string()
}
pub fn parse_profile_names(yaml: &str) -> Vec<String> {
match crate::backup_config::parse_secrets(yaml) {
Ok(secrets) => {
let mut names: Vec<String> = secrets.credentials.into_keys().collect();
names.sort();
names
}
Err(_) => vec![],
}
}
pub fn list_profiles(secrets_path: &str) -> Vec<String> {
match crate::backup_config::decrypt_sops_file(secrets_path) {
Ok(decrypted) => parse_profile_names(&decrypted),
Err(_) => vec![],
}
}
pub fn select_or_create_profile(url: &str, secrets_path: &str) -> Result<Option<String>, String> {
let mut options = list_profiles(secrets_path);
options.push(CREATE_NEW.to_string());
let choice = ask!(inquire::Select::new("Credentials profile:", options).prompt());
if choice == CREATE_NEW {
create_profile_interactive(url, secrets_path)
} else {
Ok(Some(choice))
}
}
fn create_profile_interactive(url: &str, secrets_path: &str) -> Result<Option<String>, String> {
let suggestion = suggest_profile_name(url);
let profile_name = ask!(inquire::Text::new("Profile name:")
.with_initial_value(&suggestion)
.prompt());
if profile_name.trim().is_empty() {
return Err("profile name cannot be empty".to_string());
}
let profile_name = profile_name.trim().to_string();
let existing = list_profiles(secrets_path);
if existing.contains(&profile_name) {
let overwrite = ask!(inquire::Confirm::new(&format!(
"Profile '{profile_name}' already exists. Overwrite?"
))
.with_default(false)
.prompt());
if !overwrite {
return Ok(None);
}
}
let credentials = match detect_url_type(url) {
CredentialType::B2 => {
let key_id = ask!(inquire::Password::new("B2 Application Key ID:")
.without_confirmation()
.prompt());
let key = ask!(inquire::Password::new("B2 Application Key:")
.without_confirmation()
.prompt());
let mut m = HashMap::new();
m.insert("B2_APPLICATION_KEY_ID".to_string(), key_id);
m.insert("B2_APPLICATION_KEY".to_string(), key);
m
}
CredentialType::S3 => {
let key_id = ask!(inquire::Password::new("AWS Access Key ID:")
.without_confirmation()
.prompt());
let secret = ask!(inquire::Password::new("AWS Secret Access Key:")
.without_confirmation()
.prompt());
let mut m = HashMap::new();
m.insert("AWS_ACCESS_KEY_ID".to_string(), key_id);
m.insert("AWS_SECRET_ACCESS_KEY".to_string(), secret);
m
}
CredentialType::Generic => {
let mut m = HashMap::new();
loop {
let key =
ask!(inquire::Text::new("Env var name (blank to finish):").prompt());
if key.trim().is_empty() {
break;
}
let value = ask!(inquire::Password::new(&format!("Value for {key}:"))
.without_confirmation()
.prompt());
m.insert(key, value);
}
if m.is_empty() {
return Err("at least one credential is required".to_string());
}
m
}
};
crate::backup_config::write_profile_to_secrets(secrets_path, &profile_name, &credentials)?;
Ok(Some(profile_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_b2_prefix() {
assert_eq!(detect_url_type("b2:my-bucket"), CredentialType::B2);
assert_eq!(detect_url_type("b2:bucket/path"), CredentialType::B2);
}
#[test]
fn detect_s3_prefixes() {
assert_eq!(detect_url_type("s3:https://s3.amazonaws.com/bucket"), CredentialType::S3);
assert_eq!(detect_url_type("rustfs:https://nas.example.com/bucket"), CredentialType::S3);
assert_eq!(detect_url_type("s3+https://nas.example.com/bucket"), CredentialType::S3);
}
#[test]
fn detect_generic_unknown() {
assert_eq!(detect_url_type("sftp:user@host/path"), CredentialType::Generic);
assert_eq!(detect_url_type("rclone:remote:/path"), CredentialType::Generic);
}
#[test]
fn suggest_b2_bare_bucket() {
assert_eq!(suggest_profile_name("b2:my-bucket"), "b2");
}
#[test]
fn suggest_s3_amazonaws() {
assert_eq!(suggest_profile_name("s3:https://s3.amazonaws.com/bucket"), "s3");
}
#[test]
fn suggest_rustfs_with_subdomain() {
assert_eq!(
suggest_profile_name("rustfs:https://rustfs.cinnamon-trout.ts.net/bucket"),
"rustfs"
);
}
#[test]
fn suggest_url_with_ip() {
assert_eq!(
suggest_profile_name("rustfs:https://192.168.1.1/bucket"),
"192.168.1.1"
);
}
#[test]
fn suggest_url_with_port() {
assert_eq!(suggest_profile_name("s3+https://nas:9000/bucket"), "nas");
}
#[test]
fn suggest_single_label_host() {
assert_eq!(suggest_profile_name("rustfs:https://nas/bucket"), "nas");
}
#[test]
fn parse_profiles_returns_sorted_names() {
let yaml = "restic_password: secret\ncredentials:\n nas:\n FOO: bar\n aws:\n AWS_ACCESS_KEY_ID: key\n";
let profiles = parse_profile_names(yaml);
assert_eq!(profiles, vec!["aws", "nas"]);
}
#[test]
fn parse_profiles_empty_when_no_credentials_key() {
let profiles = parse_profile_names("restic_password: secret\n");
assert!(profiles.is_empty());
}
#[test]
fn parse_profiles_empty_on_invalid_yaml() {
let profiles = parse_profile_names("not: valid: yaml: [");
assert!(profiles.is_empty());
}
}