11 Commits
Author SHA1 Message Date
DragonSlayer_14 0840f93d0d Merge pull request 'Merge dev in main: Release 1.0.2' (#15) from dev into main
Main Release & Publish / Build, Publish Packages (Stable) & Create Release (push) Successful in 9m43s
Reviewed-on: #15
2026-09-09 18:08:29 +00:00
DragonSlayer_14 42829ce0a0 **Bump: Version auf 1.0.2 erhöht** 2026-09-03 19:52:47 +02:00
DragonSlayer_14 bc3f4c4ec6 Merge remote-tracking branch 'origin/dev' into dev 2026-09-03 18:56:34 +02:00
DragonSlayer_14 c3d5ed8b26 **Feat: Erweitert Repository-Input-Handling mit robusterer Normalisierung**
- Unterstützt Query-Parameter, Fragmente und verschiedene URL-Formate
- Neue `clean_repo_input`-Funktion für konsistente Verarbeitung
- Verbesserte Tests für Edge-Cases (Anführungszeichen, `.git`, Pfade)
2026-09-03 18:56:27 +02:00
DragonSlayer_14 a7c0c25b26 **Feat: Verbessert Token-Handling und String-Sanitization**
- Leere/Whitespace-Tokens werden konsistent als `None` behandelt
- Sanitization für Anführungszeichen (`"` und `'`) und Umgebungsvariablen
- Getter-Methode für GitHub-Token hinzugefügt
2026-09-03 18:56:27 +02:00
DragonSlayer_14 08cca0ca51 Test: GitHubClient-Token-Handling verbessert 2026-09-03 18:56:27 +02:00
DragonSlayer_14 2f61e15646 Feat: Fügt Utils-Modul hinzu und integriert Umgebungsvariablen in Config 2026-09-03 18:56:27 +02:00
DragonSlayer_14 5e5e3f5b17 **Feat: Erweitert Repository-Input-Handling mit robusterer Normalisierung**
- Unterstützt Query-Parameter, Fragmente und verschiedene URL-Formate
- Neue `clean_repo_input`-Funktion für konsistente Verarbeitung
- Verbesserte Tests für Edge-Cases (Anführungszeichen, `.git`, Pfade)
2026-09-03 18:52:55 +02:00
DragonSlayer_14 f04c9611b6 **Feat: Verbessert Token-Handling und String-Sanitization**
- Leere/Whitespace-Tokens werden konsistent als `None` behandelt
- Sanitization für Anführungszeichen (`"` und `'`) und Umgebungsvariablen
- Getter-Methode für GitHub-Token hinzugefügt
2026-09-03 17:21:04 +02:00
DragonSlayer_14 14a1c4944a Test: GitHubClient-Token-Handling verbessert 2026-09-02 23:40:23 +02:00
DragonSlayer_14 c743f51526 Feat: Fügt Utils-Modul hinzu und integriert Umgebungsvariablen in Config 2026-09-02 22:31:31 +02:00
8 changed files with 301 additions and 65 deletions
Generated
+1 -1
View File
@@ -866,7 +866,7 @@ dependencies = [
[[package]]
name = "mirror-package"
version = "1.0.1"
version = "1.0.2"
dependencies = [
"anyhow",
"clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mirror-package"
version = "1.0.1"
version = "1.0.2"
edition = "2024"
authors = ['DragonSlayer_14']
readme = "README.md"
+44 -16
View File
@@ -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<String>, 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::<AppConfig>()
let mut config = config_ctdra::load_config::<AppConfig>();
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.
+9 -8
View File
@@ -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<String>) -> Result<Self> {
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() {
+1
View File
@@ -6,3 +6,4 @@ pub mod config;
pub mod gitea;
pub mod github;
pub mod pipeline;
pub mod utils;
+64
View File
@@ -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<String, String>) {
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
}
+148 -38
View File
@@ -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()));
}
+33 -1
View File
@@ -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);
}