use crate::cli::Cli;
use crate::debug::{log, FeludaError, FeludaResult, LogLevel};
use git2::Cred;
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
fn ssh_to_https_url(repo_url: &str) -> Option<String> {
if repo_url.is_empty() || repo_url.len() < "git@github.com:a/b".len() {
return None;
}
if !repo_url.starts_with("git@github.com:") {
return None;
}
let repo_path = &repo_url["git@github.com:".len()..];
if !is_valid_github_repo_path(repo_path) {
return None;
}
Some(format!("https://github.com/{repo_path}"))
}
fn is_valid_github_repo_path(repo_path: &str) -> bool {
if repo_path.is_empty() {
return false;
}
let parts: Vec<&str> = repo_path.split('/').collect();
if parts.len() != 2 {
return false;
}
let (user_or_org, repo_name) = (parts[0], parts[1]);
if user_or_org.is_empty() || repo_name.is_empty() {
return false;
}
if !is_valid_github_username(user_or_org) {
return false;
}
let repo_name_clean = repo_name.strip_suffix(".git").unwrap_or(repo_name);
if !is_valid_github_repo_name(repo_name_clean) {
return false;
}
true
}
fn is_valid_github_username(username: &str) -> bool {
if username.is_empty() || username.len() > 39 {
return false;
}
if username.starts_with('-') || username.ends_with('-') {
return false;
}
username
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn is_valid_github_repo_name(repo_name: &str) -> bool {
if repo_name.is_empty() || repo_name.len() > 100 {
return false;
}
if repo_name == "." || repo_name == ".." {
return false;
}
repo_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
fn validate_ssh_key(key_path: &Path) -> Result<(), git2::Error> {
if !key_path.exists() {
log(
LogLevel::Error,
&format!("SSH key file not found: {}", key_path.display()),
);
return Err(git2::Error::from_str("SSH key file not found"));
}
if key_path
.extension()
.map(|ext| ext == "pub")
.unwrap_or(false)
{
log(
LogLevel::Error,
&format!(
"Invalid SSH key: {} is a public key (.pub)",
key_path.display()
),
);
return Err(git2::Error::from_str(
"Public key provided instead of private key",
));
}
Ok(())
}
pub fn clone_repository(args: &Cli, dest_path: &Path) -> FeludaResult<()> {
let token = &args.token;
let ssh_key = &args.ssh_key;
let ssh_passphrase = &args.ssh_passphrase;
let repo_url = &args.repo.as_deref().unwrap();
log(
LogLevel::Info,
&format!(
"Initializing clone of {} to {}",
repo_url,
dest_path.display()
),
);
let auth_attempts = AtomicUsize::new(0);
const MAX_AUTH_ATTEMPTS: usize = 5;
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|url, username_from_url, allowed_types| {
let attempts = auth_attempts.fetch_add(1, Ordering::SeqCst);
if attempts >= MAX_AUTH_ATTEMPTS {
log(LogLevel::Error, "Max authentication attempts reached");
return Err(git2::Error::from_str("Too many authentication attempts"));
}
log(
LogLevel::Info,
&format!("Credentials callback for URL: {url}, username: {username_from_url:?}"),
);
if allowed_types.is_ssh_key() {
log(LogLevel::Info, "Attempting SSH authentication");
if let Some(key_path) = ssh_key {
let key_path = Path::new(&key_path);
validate_ssh_key(key_path)?;
log(
LogLevel::Info,
&format!("Using custom SSH key at: {}", key_path.display()),
);
Cred::ssh_key(
username_from_url.unwrap_or("git"),
None,
key_path,
ssh_passphrase.as_deref(),
)
} else {
log(LogLevel::Info, "Trying SSH agent");
match Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) {
Ok(cred) => {
log(LogLevel::Info, "Using SSH agent credentials");
Ok(cred)
}
Err(e) => {
log(
LogLevel::Warn,
&format!("SSH agent failed: {e}, trying default key"),
);
Err(e)
}
}
}
} else if allowed_types.is_user_pass_plaintext() && token.is_some() {
log(LogLevel::Info, "Using HTTPS token authentication");
Cred::userpass_plaintext("x-access-token", token.as_deref().unwrap())
} else {
log(LogLevel::Info, "Using default credentials for HTTPS");
Cred::default()
}
});
let mut fetch_options = git2::FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
let mut builder = git2::build::RepoBuilder::new();
builder.fetch_options(fetch_options);
log(
LogLevel::Info,
&format!("Cloning {} into {}", repo_url, dest_path.display()),
);
match builder.clone(repo_url, dest_path) {
Ok(_) => {
log(LogLevel::Info, "Clone successful");
Ok(())
}
Err(e) => {
if repo_url.starts_with("git@") {
if let Some(https_url) = ssh_to_https_url(repo_url) {
log(
LogLevel::Warn,
&format!("SSH clone failed: {e}, trying HTTPS: {https_url}"),
);
let mut https_callbacks = git2::RemoteCallbacks::new();
https_callbacks.credentials(|_url, _username, allowed_types| {
if allowed_types.is_user_pass_plaintext() && token.is_some() {
log(LogLevel::Info, "Using HTTPS token authentication");
Cred::userpass_plaintext("x-access-token", token.as_deref().unwrap())
} else {
log(LogLevel::Info, "Using default credentials for HTTPS");
Cred::default()
}
});
let mut https_fetch_options = git2::FetchOptions::new();
https_fetch_options.remote_callbacks(https_callbacks);
let mut https_builder = git2::build::RepoBuilder::new();
https_builder.fetch_options(https_fetch_options);
log(
LogLevel::Info,
&format!("Cloning {} into {}", https_url, dest_path.display()),
);
return match https_builder.clone(&https_url, dest_path) {
Ok(_) => {
log(LogLevel::Info, "HTTPS clone successful");
Ok(())
}
Err(e) => {
log(LogLevel::Error, &format!("HTTPS clone failed: {e}"));
Err(FeludaError::RepositoryClone(format!(
"Failed to clone repository: {e}"
)))
}
};
}
}
log(LogLevel::Error, &format!("Failed to clone repository: {e}"));
Err(FeludaError::RepositoryClone(format!(
"Failed to clone repository: {e}"
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::TempDir;
#[test]
fn test_ssh_to_https_url_github_ssh() {
let url = "git@github.com:anistark/feluda.git";
let result = ssh_to_https_url(url);
assert_eq!(
result,
Some("https://github.com/anistark/feluda.git".to_string())
);
}
#[test]
fn test_ssh_to_https_url_non_github() {
let url = "git@gitlab.com:user/repo.git";
let result = ssh_to_https_url(url);
assert_eq!(result, None);
}
#[test]
fn test_ssh_to_https_url_non_ssh() {
let url = "https://github.com/anistark/feluda.git";
let result = ssh_to_https_url(url);
assert_eq!(result, None);
}
#[test]
fn test_validate_ssh_key_exists() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("id_rsa");
File::create(&key_path).unwrap();
let result = validate_ssh_key(&key_path);
assert!(result.is_ok());
}
#[test]
fn test_validate_ssh_key_not_exists() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("id_rsa");
let result = validate_ssh_key(&key_path);
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "SSH key file not found");
}
#[test]
fn test_validate_ssh_key_public_key() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("id_rsa.pub");
File::create(&key_path).unwrap();
let result = validate_ssh_key(&key_path);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Public key provided instead of private key"
);
}
#[test]
fn test_ssh_to_https_url_various_formats() {
assert_eq!(
ssh_to_https_url("git@github.com:user/repo.git"),
Some("https://github.com/user/repo.git".to_string())
);
assert_eq!(
ssh_to_https_url("git@github.com:user/repo"),
Some("https://github.com/user/repo".to_string())
);
assert_eq!(
ssh_to_https_url("git@github.com:organization/project.git"),
Some("https://github.com/organization/project.git".to_string())
);
assert_eq!(ssh_to_https_url("https://github.com/user/repo.git"), None);
assert_eq!(ssh_to_https_url("git@gitlab.com:user/repo.git"), None);
assert_eq!(ssh_to_https_url("git@bitbucket.org:user/repo.git"), None);
assert_eq!(ssh_to_https_url("git@codeberg.org:user/repo.git"), None);
assert_eq!(ssh_to_https_url("invalid-ssh-format"), None);
assert_eq!(ssh_to_https_url("git@github.com"), None);
assert_eq!(ssh_to_https_url("git@github.com:"), None);
assert_eq!(ssh_to_https_url(""), None);
assert_eq!(
ssh_to_https_url("git@github.com:user/repo-with-dashes.git"),
Some("https://github.com/user/repo-with-dashes.git".to_string())
);
assert_eq!(
ssh_to_https_url("git@github.com:user/repo_name.git"),
Some("https://github.com/user/repo_name.git".to_string())
);
assert_eq!(ssh_to_https_url("git@GitHub.com:user/repo.git"), None);
assert_eq!(ssh_to_https_url("git@GITHUB.COM:user/repo.git"), None);
}
#[test]
fn test_validate_ssh_key_scenarios() {
let temp_dir = tempfile::TempDir::new().unwrap();
let private_key_path = temp_dir.path().join("id_rsa");
std::fs::File::create(&private_key_path).unwrap();
assert!(validate_ssh_key(&private_key_path).is_ok());
let public_key_path = temp_dir.path().join("id_rsa.pub");
std::fs::File::create(&public_key_path).unwrap();
let result = validate_ssh_key(&public_key_path);
assert!(result.is_err());
assert!(result
.unwrap_err()
.message()
.contains("Public key provided"));
let missing_key_path = temp_dir.path().join("missing_key");
let result = validate_ssh_key(&missing_key_path);
assert!(result.is_err());
assert!(result
.unwrap_err()
.message()
.contains("SSH key file not found"));
let ed25519_key_path = temp_dir.path().join("id_ed25519");
std::fs::File::create(&ed25519_key_path).unwrap();
assert!(validate_ssh_key(&ed25519_key_path).is_ok());
let ecdsa_key_path = temp_dir.path().join("id_ecdsa");
std::fs::File::create(&ecdsa_key_path).unwrap();
assert!(validate_ssh_key(&ecdsa_key_path).is_ok());
let no_ext_key_path = temp_dir.path().join("ssh_key");
std::fs::File::create(&no_ext_key_path).unwrap();
assert!(validate_ssh_key(&no_ext_key_path).is_ok());
let pub_variations = [
"key.pub",
"id_rsa.pub",
"id_ed25519.pub",
"id_ecdsa.pub",
"custom.pub",
];
for pub_key_name in &pub_variations {
let pub_key_path = temp_dir.path().join(pub_key_name);
std::fs::File::create(&pub_key_path).unwrap();
let result = validate_ssh_key(&pub_key_path);
assert!(result.is_err());
assert!(result
.unwrap_err()
.message()
.contains("Public key provided"));
}
}
#[test]
fn test_clone_repository_error_handling() {
let temp_dir = tempfile::TempDir::new().unwrap();
let args = Cli {
debug: false,
command: None,
path: "./".to_string(),
repo: Some("invalid-repo-url".to_string()),
token: None,
ssh_key: None,
ssh_passphrase: None,
github_token: None,
json: false,
yaml: false,
verbose: false,
restrictive: false,
gui: false,
language: None,
ci_format: None,
output_file: None,
fail_on_restrictive: false,
incompatible: false,
fail_on_incompatible: false,
project_license: None,
gist: false,
osi: None,
strict: false,
no_local: false,
};
let result = clone_repository(&args, temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_validate_ssh_key_permissions() {
let temp_dir = tempfile::TempDir::new().unwrap();
let key_path = temp_dir.path().join("test_key");
std::fs::write(&key_path, "fake key content").unwrap();
assert!(validate_ssh_key(&key_path).is_ok());
std::fs::remove_file(&key_path).unwrap();
let result = validate_ssh_key(&key_path);
assert!(result.is_err());
assert!(result
.unwrap_err()
.message()
.contains("SSH key file not found"));
}
#[test]
fn test_clone_repository_debug_mode() {
let temp_dir = tempfile::TempDir::new().unwrap();
let args = Cli {
debug: true,
command: None,
path: "./".to_string(),
repo: Some("https://github.com/nonexistent/repo.git".to_string()),
token: None,
ssh_key: None,
ssh_passphrase: None,
github_token: None,
json: false,
yaml: false,
verbose: false,
restrictive: false,
gui: false,
language: None,
ci_format: None,
output_file: None,
fail_on_restrictive: false,
incompatible: false,
fail_on_incompatible: false,
project_license: None,
gist: false,
osi: None,
strict: false,
no_local: false,
};
crate::debug::set_debug_mode(true);
let result = clone_repository(&args, temp_dir.path());
assert!(result.is_err());
crate::debug::set_debug_mode(false);
}
#[test]
fn test_ssh_to_https_url_case_sensitivity() {
assert_eq!(ssh_to_https_url("git@GitHub.com:user/repo.git"), None);
assert_eq!(ssh_to_https_url("git@GITHUB.COM:user/repo.git"), None);
assert_eq!(
ssh_to_https_url("git@github.com:user/repo.git"),
Some("https://github.com/user/repo.git".to_string())
);
}
#[test]
fn test_clone_repository_empty_repo_url() {
let temp_dir = tempfile::TempDir::new().unwrap();
let args = Cli {
debug: false,
command: None,
path: "./".to_string(),
repo: Some("".to_string()),
token: None,
ssh_key: None,
ssh_passphrase: None,
github_token: None,
json: false,
yaml: false,
verbose: false,
restrictive: false,
gui: false,
language: None,
ci_format: None,
output_file: None,
fail_on_restrictive: false,
incompatible: false,
fail_on_incompatible: false,
project_license: None,
gist: false,
osi: None,
strict: false,
no_local: false,
};
let result = clone_repository(&args, temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_ssh_to_https_url_validation() {
assert_eq!(
ssh_to_https_url("git@github.com:microsoft/vscode.git"),
Some("https://github.com/microsoft/vscode.git".to_string())
);
assert_eq!(
ssh_to_https_url("git@github.com:user-name/repo-name.git"),
Some("https://github.com/user-name/repo-name.git".to_string())
);
assert_eq!(
ssh_to_https_url("git@github.com:user123/repo_name.config.git"),
Some("https://github.com/user123/repo_name.config.git".to_string())
);
assert_eq!(ssh_to_https_url("git@github.com:-user/repo.git"), None);
assert_eq!(ssh_to_https_url("git@github.com:user-/repo.git"), None);
assert_eq!(ssh_to_https_url("git@github.com:user/.git"), None);
assert_eq!(ssh_to_https_url("git@github.com:user/..git"), None);
assert_eq!(ssh_to_https_url("git@github.com:user/repo/extra"), None);
let long_username = "a".repeat(40);
assert_eq!(
ssh_to_https_url(&format!("git@github.com:{long_username}/repo.git")),
None
);
let long_repo = "a".repeat(101);
assert_eq!(
ssh_to_https_url(&format!("git@github.com:user/{long_repo}.git")),
None
);
assert_eq!(ssh_to_https_url("git@github.com:/repo.git"), None);
assert_eq!(ssh_to_https_url("git@github.com:user/"), None);
}
#[test]
fn test_ssh_to_https_url_special_characters() {
assert_eq!(
ssh_to_https_url("git@github.com:user/my-project.js.git"),
Some("https://github.com/user/my-project.js.git".to_string())
);
assert_eq!(
ssh_to_https_url("git@github.com:user/config_file.json"),
Some("https://github.com/user/config_file.json".to_string())
);
assert_eq!(
ssh_to_https_url("git@github.com:user-name/repo.git"),
Some("https://github.com/user-name/repo.git".to_string())
);
assert_eq!(ssh_to_https_url("git@github.com:user_name/repo.git"), None);
assert_eq!(ssh_to_https_url("git@github.com:user@name/repo.git"), None);
assert_eq!(ssh_to_https_url("git@github.com:user/repo@name.git"), None);
assert_eq!(ssh_to_https_url("git@github.com:user name/repo.git"), None);
}
}