diff --git a/Cargo.lock b/Cargo.lock index 034c533..acc769d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -866,7 +866,7 @@ dependencies = [ [[package]] name = "mirror-package" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 1cfe334..c7c07d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mirror-package" -version = "1.0.1" +version = "1.0.2" edition = "2024" authors = ['DragonSlayer_14'] readme = "README.md" diff --git a/src/config.rs b/src/config.rs index 40a537b..9c2bafe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use crate::utils::{clean_repo_input, sanitize_string}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -21,7 +22,7 @@ pub struct RepoConfig { impl RepoConfig { pub fn new(name: impl Into, include_prereleases: bool) -> Self { Self { - name: name.into(), + name: AppConfig::normalize_repo_name(&name.into()), include_prereleases, last_synced_tag: None, } @@ -49,21 +50,17 @@ pub struct AppConfig { } impl AppConfig { - /// Normalisiert die Repository-Eingabe, indem führende/nachgestellte Schrägstriche und GitHub-URL-Präfixe entfernt werden. + /// Normalisiert die Repository-Eingabe, indem führende/nachgestellte Schrägstriche, + /// Query-Parameter, Fragmente und GitHub-URL-Präfixe entfernt werden. pub fn normalize_repo_name(input: &str) -> String { - let trimmed = input.trim(); - let cleaned = trimmed - .trim_start_matches("https://github.com/") - .trim_start_matches("http://github.com/") - .trim_start_matches("github.com/") - .trim_end_matches(".git") - .trim_matches('/'); - cleaned.to_string() + clean_repo_input(input).to_string() } /// Fügt ein Repository zur Konfiguration hinzu oder aktualisiert ein bestehendes. - pub fn add_or_update_repo(&mut self, repo: RepoConfig) { - if let Some(existing) = self.repositories.iter_mut().find(|r| r.name.eq_ignore_ascii_case(&repo.name)) { + pub fn add_or_update_repo(&mut self, mut repo: RepoConfig) { + repo.name = Self::normalize_repo_name(&repo.name); + if let Some(existing) = self.repositories.iter_mut().find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&repo.name)) { + existing.name = repo.name; existing.include_prereleases = repo.include_prereleases; } else { self.repositories.push(repo); @@ -74,20 +71,20 @@ impl AppConfig { pub fn remove_repo(&mut self, repo_name: &str) -> bool { let normalized = Self::normalize_repo_name(repo_name); let before_len = self.repositories.len(); - self.repositories.retain(|r| !r.name.eq_ignore_ascii_case(&normalized)); + self.repositories.retain(|r| !Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)); self.repositories.len() < before_len } /// Sucht ein Repository anhand des Namens. pub fn find_repo(&self, repo_name: &str) -> Option<&RepoConfig> { let normalized = Self::normalize_repo_name(repo_name); - self.repositories.iter().find(|r| r.name.eq_ignore_ascii_case(&normalized)) + self.repositories.iter().find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)) } /// Aktualisiert den zuletzt synchronisierten Tag für ein bestimmtes Repository. pub fn update_last_synced_tag(&mut self, repo_name: &str, tag: String) { let normalized = Self::normalize_repo_name(repo_name); - if let Some(repo) = self.repositories.iter_mut().find(|r| r.name.eq_ignore_ascii_case(&normalized)) { + if let Some(repo) = self.repositories.iter_mut().find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)) { repo.last_synced_tag = Some(tag); } } @@ -108,7 +105,38 @@ pub fn get_config_file_path() -> PathBuf { /// Lädt die Anwendungskonfiguration. Verwendet Standardwerte, falls keine Datei vorhanden ist. pub fn load_config() -> AppConfig { - config_ctdra::load_config::() + let mut config = config_ctdra::load_config::(); + + for repo in &mut config.repositories { + repo.name = AppConfig::normalize_repo_name(&repo.name); + } + + if let Ok(val) = std::env::var("GITEA_URL") { + let val = sanitize_string(&val); + if !val.is_empty() { + config.gitea_url = Some(val); + } + } + if let Ok(val) = std::env::var("GITEA_TOKEN") { + let val = sanitize_string(&val); + if !val.is_empty() { + config.gitea_token = Some(val); + } + } + if let Ok(val) = std::env::var("REGISTRY_OWNER") { + let val = sanitize_string(&val); + if !val.is_empty() { + config.registry_owner = Some(val); + } + } + if let Ok(val) = std::env::var("GITHUB_TOKEN") { + let val = sanitize_string(&val); + if !val.is_empty() { + config.github_token = Some(val); + } + } + + config } /// Speichert die Anwendungskonfiguration auf der Festplatte. diff --git a/src/github.rs b/src/github.rs index 03253d4..231a8cd 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; /// Unterstützte Linux-Paketverteilungstypen. @@ -87,6 +87,7 @@ pub struct GitHubClient { impl GitHubClient { /// Erstellt einen neuen GitHub-API-Client mit optionalem Authentifizierungstoken. pub fn new(token: Option) -> Result { + let token = token.filter(|t| !t.trim().is_empty()); let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::USER_AGENT, @@ -105,6 +106,12 @@ impl GitHubClient { Ok(Self { client, token }) } + /// Gibt das optionale Authentifizierungstoken zurück (falls gesetzt). + pub fn token(&self) -> Option<&str> { + self.token.as_deref() + } + + /// Ruft Releases für ein angegebenes Repository ab (Format "owner/repo"). /// /// Wenn `history` false ist, werden nur die neuesten Releases abgerufen. @@ -239,13 +246,7 @@ impl GitHubClient { /// Hilfsfunktion zum Parsen von "owner/repo" aus einer Repository-Zeichenkette. pub fn parse_repo_owner_name(repo: &str) -> Result<(&str, &str)> { - let cleaned = repo - .trim() - .trim_start_matches("https://github.com/") - .trim_start_matches("http://github.com/") - .trim_start_matches("github.com/") - .trim_end_matches(".git") - .trim_matches('/'); + let cleaned = crate::utils::clean_repo_input(repo); let parts: Vec<&str> = cleaned.split('/').collect(); if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { diff --git a/src/lib.rs b/src/lib.rs index 4d04728..16da4ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,4 @@ pub mod config; pub mod gitea; pub mod github; pub mod pipeline; +pub mod utils; diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..317d8e9 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,64 @@ + +/// Sanitizes a string by removing surrounding quotes (single or double). +/// +/// # Arguments +/// +/// * `input` - The string to sanitize. +/// +/// # Returns +/// +/// The sanitized string with surrounding quotes removed. +pub fn sanitize_string(input: &str) -> String { + let trimmed = input.trim(); + if let Some(stripped) = trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"')) { + stripped.to_string() + } else if let Some(stripped) = trimmed.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) { + stripped.to_string() + } else { + trimmed.to_string() + } +} + +/// Sanitizes environment variables by removing surrounding quotes. +/// +/// # Arguments +/// +/// * `env_vars` - A reference to a mutable map of environment variables. +pub fn sanitize_env_vars(env_vars: &mut std::collections::HashMap) { + for (_key, value) in env_vars.iter_mut() { + *value = sanitize_string(value); + } +} + +/// Bereinigt eine Repository-Eingabe (URL oder Kurzform), indem Query-Parameter, Fragmente, +/// URL-Schemata/Hosts (`github.com`, `www.github.com`, `git@github.com:`), `.git`-Endungen +/// und umgebende Schrägstriche entfernt werden. +pub fn clean_repo_input(input: &str) -> &str { + let mut trimmed = input.trim(); + if (trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'')) + { + if trimmed.len() >= 2 { + trimmed = trimmed[1..trimmed.len() - 1].trim(); + } + } + + let without_query_or_fragment = match trimmed.find(|c| c == '?' || c == '#') { + Some(idx) => &trimmed[..idx], + None => trimmed, + }; + + let mut cleaned = without_query_or_fragment + .trim_start_matches("git@github.com:") + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_start_matches("www.github.com/") + .trim_start_matches("github.com/") + .trim_matches('/'); + + if let Some(stripped) = cleaned.strip_suffix(".git") { + cleaned = stripped.trim_matches('/'); + } + + cleaned +} diff --git a/tests/config_tests.rs b/tests/config_tests.rs index c844d4b..972e675 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -1,53 +1,163 @@ -use mirror_package::config::{AppConfig, RepoConfig}; +use mirror_package::config::{AppConfig, RepoConfig, load_config}; +use mirror_package::utils::{clean_repo_input, sanitize_env_vars, sanitize_string}; +use std::collections::HashMap; + +#[test] +fn test_sanitize_env_vars() { + let mut env_vars = HashMap::new(); + env_vars.insert("GITEA_URL".to_string(), "\"https://gitea.example.com\"".to_string()); + env_vars.insert("GITEA_TOKEN".to_string(), "\"token123\"".to_string()); + env_vars.insert("REGISTRY_OWNER".to_string(), "\"owner\"".to_string()); + env_vars.insert("GITHUB_TOKEN".to_string(), "\"github_token123\"".to_string()); + + sanitize_env_vars(&mut env_vars); + + assert_eq!(env_vars.get("GITEA_URL").unwrap(), "https://gitea.example.com"); + assert_eq!(env_vars.get("GITEA_TOKEN").unwrap(), "token123"); + assert_eq!(env_vars.get("REGISTRY_OWNER").unwrap(), "owner"); + assert_eq!(env_vars.get("GITHUB_TOKEN").unwrap(), "github_token123"); +} + +#[test] +fn test_load_config_with_env_vars() { + // This test relies on environment variables being set externally. + // To avoid `unsafe` blocks, we skip setting them programmatically. + // In a real test environment, set these variables before running the test: + // GITEA_URL="https://gitea.example.com" + // GITEA_TOKEN="token123" + // REGISTRY_OWNER="owner" + // GITHUB_TOKEN="github_token123" + let config = load_config(); + + // Only assert if the environment variables are set + if std::env::var("GITEA_URL").is_ok() { + assert_eq!(config.gitea_url, Some("https://gitea.example.com".to_string())); + assert_eq!(config.gitea_token, Some("token123".to_string())); + assert_eq!(config.registry_owner, Some("owner".to_string())); + assert_eq!(config.github_token, Some("github_token123".to_string())); + } +} + +#[test] +fn test_sanitize_string() { + assert_eq!(sanitize_string("\"https://gitea.example.com\""), "https://gitea.example.com"); + assert_eq!(sanitize_string("'https://gitea.example.com'"), "https://gitea.example.com"); + assert_eq!(sanitize_string("\"token123\""), "token123"); + assert_eq!(sanitize_string("'token123'"), "token123"); + assert_eq!(sanitize_string("\"owner\""), "owner"); + assert_eq!(sanitize_string("'owner'"), "owner"); + assert_eq!(sanitize_string("\"github_token123\""), "github_token123"); + assert_eq!(sanitize_string("'github_token123'"), "github_token123"); + assert_eq!(sanitize_string("no_quotes"), "no_quotes"); + assert_eq!(sanitize_string("\"single_quote\""), "single_quote"); + assert_eq!(sanitize_string("'single_quote'"), "single_quote"); + assert_eq!(sanitize_string("\"escaped_quote\""), "escaped_quote"); + // Edge cases: single character inputs + assert_eq!(sanitize_string("\""), "\""); + assert_eq!(sanitize_string("'"), "'"); + assert_eq!(sanitize_string("a"), "a"); + // Edge cases: empty quotes and empty strings + assert_eq!(sanitize_string("\"\""), ""); + assert_eq!(sanitize_string("''"), ""); + assert_eq!(sanitize_string(""), ""); + assert_eq!(sanitize_string(" "), ""); + assert_eq!(sanitize_string(" 'hello' "), "hello"); + assert_eq!(sanitize_string(" \"world\" "), "world"); +} + +#[test] +fn test_clean_repo_input() { + assert_eq!(clean_repo_input("owner/repo"), "owner/repo"); + assert_eq!(clean_repo_input("https://github.com/owner/repo"), "owner/repo"); + assert_eq!(clean_repo_input("http://github.com/owner/repo"), "owner/repo"); + assert_eq!(clean_repo_input("github.com/owner/repo"), "owner/repo"); + assert_eq!(clean_repo_input("https://github.com/owner/repo.git"), "owner/repo"); + assert_eq!(clean_repo_input("https://github.com/owner/repo/"), "owner/repo"); + assert_eq!( + clean_repo_input("https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file"), + "raspberrypi/rpi-imager" + ); + assert_eq!( + clean_repo_input("https://github.com/raspberrypi/rpi-imager/?tab=readme-ov-file"), + "raspberrypi/rpi-imager" + ); + assert_eq!( + clean_repo_input("https://github.com/raspberrypi/rpi-imager.git?tab=readme-ov-file"), + "raspberrypi/rpi-imager" + ); + assert_eq!( + clean_repo_input("https://github.com/raspberrypi/rpi-imager#readme"), + "raspberrypi/rpi-imager" + ); + assert_eq!( + clean_repo_input("https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file#install"), + "raspberrypi/rpi-imager" + ); + assert_eq!( + clean_repo_input("raspberrypi/rpi-imager?tab=readme-ov-file"), + "raspberrypi/rpi-imager" + ); + assert_eq!( + clean_repo_input("\"https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file\""), + "raspberrypi/rpi-imager" + ); +} #[test] fn test_normalize_repo_name() { + let input = "https://github.com/owner/repo.git"; + let normalized = AppConfig::normalize_repo_name(input); + assert_eq!(normalized, "owner/repo"); + + let input_with_query = "https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file"; assert_eq!( - AppConfig::normalize_repo_name("raspberrypi/rpi-imager"), + AppConfig::normalize_repo_name(input_with_query), "raspberrypi/rpi-imager" ); - assert_eq!( - AppConfig::normalize_repo_name("https://github.com/raspberrypi/rpi-imager"), - "raspberrypi/rpi-imager" - ); - assert_eq!( - AppConfig::normalize_repo_name("https://github.com/raspberrypi/rpi-imager.git"), - "raspberrypi/rpi-imager" - ); - assert_eq!( - AppConfig::normalize_repo_name("Heroic-Games-Launcher/HeroicGamesLauncher/"), - "Heroic-Games-Launcher/HeroicGamesLauncher" - ); } #[test] -fn test_repo_management() { +fn test_add_or_update_repo() { let mut config = AppConfig::default(); - config.add_or_update_repo(RepoConfig::new("owner/repo1", true)); - config.add_or_update_repo(RepoConfig::new("owner/repo2", false)); - - assert_eq!(config.repositories.len(), 2); - assert!(config.find_repo("owner/repo1").is_some()); - assert!(config.find_repo("https://github.com/owner/repo1").is_some()); - - assert!(config.remove_repo("owner/repo1")); + let repo = RepoConfig::new("https://github.com/owner/repo?tab=readme-ov-file", true); + config.add_or_update_repo(repo.clone()); assert_eq!(config.repositories.len(), 1); - assert!(config.find_repo("owner/repo1").is_none()); + assert_eq!(config.repositories[0].name, "owner/repo"); + assert_eq!(config.repositories[0].include_prereleases, true); + + // Updating existing repo with a URL with query param + let repo_updated = RepoConfig::new("https://github.com/owner/repo#readme", false); + config.add_or_update_repo(repo_updated); + assert_eq!(config.repositories.len(), 1); + assert_eq!(config.repositories[0].name, "owner/repo"); + assert_eq!(config.repositories[0].include_prereleases, false); } #[test] -fn test_serialization_roundtrip() { - let mut config = AppConfig { - gitea_url: Some("https://gitea.example.com".to_string()), - gitea_token: Some("secret_token".to_string()), - registry_owner: Some("my-org".to_string()), - github_token: Some("gh_pat".to_string()), - repositories: Vec::new(), - }; - config.add_or_update_repo(RepoConfig::new("raspberrypi/rpi-imager", true)); - - let json_str = serde_json::to_string(&config).unwrap(); - let deserialized: AppConfig = serde_json::from_str(&json_str).unwrap(); - - assert_eq!(config, deserialized); +fn test_remove_repo() { + let mut config = AppConfig::default(); + let repo = RepoConfig::new("owner/repo", true); + config.add_or_update_repo(repo.clone()); + assert!(config.remove_repo("https://github.com/owner/repo?tab=readme-ov-file")); + assert!(config.repositories.is_empty()); } + +#[test] +fn test_find_repo() { + let mut config = AppConfig::default(); + let repo = RepoConfig::new("owner/repo", true); + config.add_or_update_repo(repo.clone()); + assert_eq!( + config.find_repo("https://github.com/owner/repo?tab=readme-ov-file"), + Some(&RepoConfig::new("owner/repo", true)) + ); +} + +#[test] +fn test_update_last_synced_tag() { + let mut config = AppConfig::default(); + let repo = RepoConfig::new("owner/repo", true); + config.add_or_update_repo(repo.clone()); + config.update_last_synced_tag("owner/repo", "v1.0.0".to_string()); + assert_eq!(config.find_repo("owner/repo").unwrap().last_synced_tag, Some("v1.0.0".to_string())); +} \ No newline at end of file diff --git a/tests/github_tests.rs b/tests/github_tests.rs index 1a3b4f4..16ad8dc 100644 --- a/tests/github_tests.rs +++ b/tests/github_tests.rs @@ -1,4 +1,4 @@ -use mirror_package::github::{parse_repo_owner_name, PackageType}; +use mirror_package::github::{PackageType, parse_repo_owner_name}; #[test] fn test_package_classification() { @@ -64,7 +64,39 @@ fn test_parse_repo_owner_name() { parse_repo_owner_name("https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher.git").unwrap(), ("Heroic-Games-Launcher", "HeroicGamesLauncher") ); + assert_eq!( + parse_repo_owner_name("https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file").unwrap(), + ("raspberrypi", "rpi-imager") + ); + assert_eq!( + parse_repo_owner_name("https://github.com/raspberrypi/rpi-imager/?tab=readme-ov-file#install").unwrap(), + ("raspberrypi", "rpi-imager") + ); + assert_eq!( + parse_repo_owner_name("raspberrypi/rpi-imager?tab=readme-ov-file").unwrap(), + ("raspberrypi", "rpi-imager") + ); assert!(parse_repo_owner_name("invalid_repo").is_err()); assert!(parse_repo_owner_name("invalid/repo/extra").is_err()); } + +#[tokio::test] +async fn test_github_client_empty_token() { + use mirror_package::github::GitHubClient; + + // Test that a client with an empty or whitespace token sanitizes it to None + let client = GitHubClient::new(Some("".to_string())).expect("Failed to create client with empty token"); + assert_eq!(client.token(), None); + + let client_whitespace = GitHubClient::new(Some(" ".to_string())).expect("Failed to create client with whitespace token"); + assert_eq!(client_whitespace.token(), None); + + // Test with a valid token + let client_with_token = GitHubClient::new(Some("valid_token".to_string())).expect("Failed to create client with valid token"); + assert_eq!(client_with_token.token(), Some("valid_token")); + + // Test with no token + let client_no_token = GitHubClient::new(None).expect("Failed to create client with no token"); + assert_eq!(client_no_token.token(), None); +}