feat(repos): fetch Chaotic-AUR mirrors dynamically from official mirrorlist
- Add mirrorlist.go with FetchChaoticMirrorlist and BuildPackageURLs functions - Parse official Chaotic-AUR mirrorlist from GitLab - Prioritize enabled mirrors, fallback to disabled ones - Add comprehensive unit tests (9 tests covering parsing, URL building, edge cases) - Improve error messages with network/HTTP details - Log mirror statistics (total, enabled, disabled counts) - Remove hardcoded mirror URLs from config file
- commit
- c45daf93780cfa878cd56eacacfb935d644f0020
- author
- bnema · · 2025-10-27 17:46 UTC
- committer
- bnema · · 2025-10-27 18:25 UTC
- parent
- 774a055
- signature
- unsigned
4 files · +477 -20
M install/configs/chaotic-aur.conf +4 -6
| @@ -5,12 +5,10 @@ | |||
| 5 | 5 | CHAOTIC_KEY_ID=3056513887B78AEB | |
| 6 | 6 | CHAOTIC_KEYSERVER=keyserver.ubuntu.com | |
| 7 | 7 | ||
| 8 | - | # Package URLs with mirror fallbacks | |
| 9 | - | # Primary and fallback mirrors are pipe-separated; function will try in order | |
| 10 | - | # Main site is always available as primary; if it's down, mirrors will be attempted | |
| 11 | - | CHAOTIC_KEYRING_URL=https://builds.garudalinux.org/repos/chaotic-aur/chaotic-keyring.pkg.tar.zst|https://chaotic.pl4ygr0und.de/chaotic-aur/chaotic-keyring.pkg.tar.zst|https://repo.pkgbuilds.com/chaotic-aur/chaotic-keyring.pkg.tar.zst | |
| 12 | - | CHAOTIC_MIRRORLIST_URL=https://builds.garudalinux.org/repos/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst|https://chaotic.pl4ygr0und.de/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst|https://repo.pkgbuilds.com/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst | |
| 13 | - | ||
| 14 | 8 | # Repository Configuration | |
| 15 | 9 | CHAOTIC_REPO_NAME=chaotic-aur | |
| 16 | 10 | CHAOTIC_MIRRORLIST_PATH=/etc/pacman.d/chaotic-mirrorlist | |
| 11 | + | ||
| 12 | + | # Note: Package mirrors are fetched dynamically from the official Chaotic-AUR mirrorlist | |
| 13 | + | # Source: https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist | |
| 14 | + | # This ensures always up-to-date mirrors without manual configuration | |
| @@ -5,12 +5,10 @@ | |||||
| 5 | CHAOTIC_KEY_ID=3056513887B78AEB | 5 | CHAOTIC_KEY_ID=3056513887B78AEB | ||
| 6 | CHAOTIC_KEYSERVER=keyserver.ubuntu.com | 6 | CHAOTIC_KEYSERVER=keyserver.ubuntu.com | ||
| 7 | 7 | ||||
| 8 | - | # Package URLs with mirror fallbacks | |||
| 9 | - | # Primary and fallback mirrors are pipe-separated; function will try in order | |||
| 10 | - | # Main site is always available as primary; if it's down, mirrors will be attempted | |||
| 11 | - | CHAOTIC_KEYRING_URL=https://builds.garudalinux.org/repos/chaotic-aur/chaotic-keyring.pkg.tar.zst|https://chaotic.pl4ygr0und.de/chaotic-aur/chaotic-keyring.pkg.tar.zst|https://repo.pkgbuilds.com/chaotic-aur/chaotic-keyring.pkg.tar.zst | |||
| 12 | - | CHAOTIC_MIRRORLIST_URL=https://builds.garudalinux.org/repos/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst|https://chaotic.pl4ygr0und.de/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst|https://repo.pkgbuilds.com/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst | |||
| 13 | - | ||||
| 14 | # Repository Configuration | 8 | # Repository Configuration | ||
| 15 | CHAOTIC_REPO_NAME=chaotic-aur | 9 | CHAOTIC_REPO_NAME=chaotic-aur | ||
| 16 | CHAOTIC_MIRRORLIST_PATH=/etc/pacman.d/chaotic-mirrorlist | 10 | CHAOTIC_MIRRORLIST_PATH=/etc/pacman.d/chaotic-mirrorlist | ||
| 11 | + | ||||
| 12 | + | # Note: Package mirrors are fetched dynamically from the official Chaotic-AUR mirrorlist | |||
| 13 | + | # Source: https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist | |||
| 14 | + | # This ensures always up-to-date mirrors without manual configuration | |||
M internal/phases/repos.go +49 -14
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/archup/internal/config" | |
| 10 | 10 | "github.com/bnema/archup/internal/interfaces" | |
| 11 | 11 | "github.com/bnema/archup/internal/logger" | |
| 12 | + | "github.com/bnema/archup/internal/system" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | // Pacman configuration constants | |
| @@ -270,7 +271,7 @@ func (p *ReposPhase) loadChaoticConfig() error { | |||
| 270 | 271 | func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error { | |
| 271 | 272 | p.SendOutput(progressChan, "Adding Chaotic-AUR repository...") | |
| 272 | 273 | ||
| 273 | - | // Load configuration | |
| 274 | + | // Load configuration for GPG key settings | |
| 274 | 275 | switch err := p.loadChaoticConfig(); { | |
| 275 | 276 | case err != nil: | |
| 276 | 277 | return fmt.Errorf("failed to load Chaotic config: %w", err) | |
| @@ -278,8 +279,6 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error | |||
| 278 | 279 | ||
| 279 | 280 | keyID := p.chaoticConfig["CHAOTIC_KEY_ID"] | |
| 280 | 281 | keyserver := p.chaoticConfig["CHAOTIC_KEYSERVER"] | |
| 281 | - | keyringURL := p.chaoticConfig["CHAOTIC_KEYRING_URL"] | |
| 282 | - | mirrorlistURL := p.chaoticConfig["CHAOTIC_MIRRORLIST_URL"] | |
| 283 | 282 | repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"] | |
| 284 | 283 | mirrorlistPath := p.chaoticConfig["CHAOTIC_MIRRORLIST_PATH"] | |
| 285 | 284 | ||
| @@ -303,22 +302,58 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error | |||
| 303 | 302 | ||
| 304 | 303 | p.SendOutput(progressChan, "[OK] Chaotic GPG key imported") | |
| 305 | 304 | ||
| 306 | - | // Download and install chaotic-keyring and chaotic-mirrorlist | |
| 307 | - | p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...") | |
| 305 | + | // Fetch live mirrorlist from GitLab | |
| 306 | + | p.SendOutput(progressChan, "Fetching Chaotic-AUR mirrors...") | |
| 307 | + | mirrors, err := system.FetchChaoticMirrorlist() | |
| 308 | + | switch { | |
| 309 | + | case err != nil: | |
| 310 | + | p.logger.Warn("Failed to fetch live Chaotic mirrors from GitLab, skipping Chaotic-AUR", "error", err) | |
| 311 | + | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (mirror fetch failed), skipping") | |
| 312 | + | return nil // Don't fail, just skip | |
| 313 | + | } | |
| 308 | 314 | ||
| 309 | - | // Parse mirror URLs (pipe-separated for fallbacks) | |
| 310 | - | var urls []string | |
| 311 | - | for _, url := range strings.Split(keyringURL, "|") { | |
| 312 | - | urls = append(urls, strings.TrimSpace(url)) | |
| 315 | + | // Count enabled vs disabled mirrors | |
| 316 | + | enabledCount := 0 | |
| 317 | + | for _, m := range mirrors { | |
| 318 | + | if m.Enabled { | |
| 319 | + | enabledCount++ | |
| 320 | + | } | |
| 313 | 321 | } | |
| 314 | - | for _, url := range strings.Split(mirrorlistURL, "|") { | |
| 315 | - | urls = append(urls, strings.TrimSpace(url)) | |
| 322 | + | ||
| 323 | + | p.logger.Info("Fetched live Chaotic-AUR mirrors from GitLab", | |
| 324 | + | "total", len(mirrors), | |
| 325 | + | "enabled", enabledCount, | |
| 326 | + | "disabled", len(mirrors)-enabledCount) | |
| 327 | + | ||
| 328 | + | // Build package URLs from live mirrors | |
| 329 | + | const ( | |
| 330 | + | keyringPkg = "chaotic-keyring.pkg.tar.zst" | |
| 331 | + | mirrorlistPkg = "chaotic-mirrorlist.pkg.tar.zst" | |
| 332 | + | ) | |
| 333 | + | ||
| 334 | + | keyringURLs := system.BuildPackageURLs(mirrors, keyringPkg, true) | |
| 335 | + | mirrorlistURLs := system.BuildPackageURLs(mirrors, mirrorlistPkg, true) | |
| 336 | + | ||
| 337 | + | if len(keyringURLs) == 0 || len(mirrorlistURLs) == 0 { | |
| 338 | + | p.logger.Warn("Failed to build package URLs from mirrors, skipping Chaotic-AUR") | |
| 339 | + | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (no valid mirrors), skipping") | |
| 340 | + | return nil // Don't fail, just skip | |
| 316 | 341 | } | |
| 317 | 342 | ||
| 343 | + | // Combine all URLs for download | |
| 344 | + | var urls []string | |
| 345 | + | urls = append(urls, keyringURLs...) | |
| 346 | + | urls = append(urls, mirrorlistURLs...) | |
| 347 | + | ||
| 348 | + | p.logger.Info("Built package URLs from live mirrors", "keyring_urls", len(keyringURLs), "mirrorlist_urls", len(mirrorlistURLs)) | |
| 349 | + | ||
| 350 | + | // Download and install chaotic-keyring and chaotic-mirrorlist | |
| 351 | + | p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...") | |
| 352 | + | ||
| 318 | 353 | switch err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, urls...); { | |
| 319 | 354 | case err != nil: | |
| 320 | - | p.logger.Warn("Chaotic-AUR packages unavailable, skipping", "error", err) | |
| 321 | - | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable, skipping (AUR helper will be built from source)") | |
| 355 | + | p.logger.Warn("Failed to download/install Chaotic packages, skipping Chaotic-AUR", "error", err) | |
| 356 | + | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (package download failed), skipping") | |
| 322 | 357 | return nil // IMPORTANT: Exit early, don't add to pacman.conf | |
| 323 | 358 | } | |
| 324 | 359 | ||
| @@ -351,7 +386,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error | |||
| 351 | 386 | } | |
| 352 | 387 | ||
| 353 | 388 | // Update package databases | |
| 354 | - | switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "pacman -Sy --noconfirm"); { | |
| 389 | + | switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); { | |
| 355 | 390 | case err != nil: | |
| 356 | 391 | return fmt.Errorf("failed to update package databases: %w", err) | |
| 357 | 392 | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/archup/internal/config" | 9 | "github.com/bnema/archup/internal/config" | ||
| 10 | "github.com/bnema/archup/internal/interfaces" | 10 | "github.com/bnema/archup/internal/interfaces" | ||
| 11 | "github.com/bnema/archup/internal/logger" | 11 | "github.com/bnema/archup/internal/logger" | ||
| 12 | + | "github.com/bnema/archup/internal/system" | |||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | // Pacman configuration constants | 15 | // Pacman configuration constants | ||
| @@ -270,7 +271,7 @@ func (p *ReposPhase) loadChaoticConfig() error { | |||||
| 270 | func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error { | 271 | func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error { | ||
| 271 | p.SendOutput(progressChan, "Adding Chaotic-AUR repository...") | 272 | p.SendOutput(progressChan, "Adding Chaotic-AUR repository...") | ||
| 272 | 273 | ||||
| 273 | - | // Load configuration | 274 | + | // Load configuration for GPG key settings |
| 274 | switch err := p.loadChaoticConfig(); { | 275 | switch err := p.loadChaoticConfig(); { | ||
| 275 | case err != nil: | 276 | case err != nil: | ||
| 276 | return fmt.Errorf("failed to load Chaotic config: %w", err) | 277 | return fmt.Errorf("failed to load Chaotic config: %w", err) | ||
| @@ -278,8 +279,6 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error | |||||
| 278 | 279 | ||||
| 279 | keyID := p.chaoticConfig["CHAOTIC_KEY_ID"] | 280 | keyID := p.chaoticConfig["CHAOTIC_KEY_ID"] | ||
| 280 | keyserver := p.chaoticConfig["CHAOTIC_KEYSERVER"] | 281 | keyserver := p.chaoticConfig["CHAOTIC_KEYSERVER"] | ||
| 281 | - | keyringURL := p.chaoticConfig["CHAOTIC_KEYRING_URL"] | |||
| 282 | - | mirrorlistURL := p.chaoticConfig["CHAOTIC_MIRRORLIST_URL"] | |||
| 283 | repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"] | 282 | repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"] | ||
| 284 | mirrorlistPath := p.chaoticConfig["CHAOTIC_MIRRORLIST_PATH"] | 283 | mirrorlistPath := p.chaoticConfig["CHAOTIC_MIRRORLIST_PATH"] | ||
| 285 | 284 | ||||
| @@ -303,22 +302,58 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error | |||||
| 303 | 302 | ||||
| 304 | p.SendOutput(progressChan, "[OK] Chaotic GPG key imported") | 303 | p.SendOutput(progressChan, "[OK] Chaotic GPG key imported") | ||
| 305 | 304 | ||||
| 306 | - | // Download and install chaotic-keyring and chaotic-mirrorlist | 305 | + | // Fetch live mirrorlist from GitLab |
| 307 | - | p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...") | 306 | + | p.SendOutput(progressChan, "Fetching Chaotic-AUR mirrors...") |
| 307 | + | mirrors, err := system.FetchChaoticMirrorlist() | |||
| 308 | + | switch { | |||
| 309 | + | case err != nil: | |||
| 310 | + | p.logger.Warn("Failed to fetch live Chaotic mirrors from GitLab, skipping Chaotic-AUR", "error", err) | |||
| 311 | + | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (mirror fetch failed), skipping") | |||
| 312 | + | return nil // Don't fail, just skip | |||
| 313 | + | } | |||
| 308 | 314 | ||||
| 309 | - | // Parse mirror URLs (pipe-separated for fallbacks) | 315 | + | // Count enabled vs disabled mirrors |
| 310 | - | var urls []string | 316 | + | enabledCount := 0 |
| 311 | - | for _, url := range strings.Split(keyringURL, "|") { | 317 | + | for _, m := range mirrors { |
| 312 | - | urls = append(urls, strings.TrimSpace(url)) | 318 | + | if m.Enabled { |
| 319 | + | enabledCount++ | |||
| 320 | + | } | |||
| 313 | } | 321 | } | ||
| 314 | - | for _, url := range strings.Split(mirrorlistURL, "|") { | 322 | + | |
| 315 | - | urls = append(urls, strings.TrimSpace(url)) | 323 | + | p.logger.Info("Fetched live Chaotic-AUR mirrors from GitLab", |
| 324 | + | "total", len(mirrors), | |||
| 325 | + | "enabled", enabledCount, | |||
| 326 | + | "disabled", len(mirrors)-enabledCount) | |||
| 327 | + | ||||
| 328 | + | // Build package URLs from live mirrors | |||
| 329 | + | const ( | |||
| 330 | + | keyringPkg = "chaotic-keyring.pkg.tar.zst" | |||
| 331 | + | mirrorlistPkg = "chaotic-mirrorlist.pkg.tar.zst" | |||
| 332 | + | ) | |||
| 333 | + | ||||
| 334 | + | keyringURLs := system.BuildPackageURLs(mirrors, keyringPkg, true) | |||
| 335 | + | mirrorlistURLs := system.BuildPackageURLs(mirrors, mirrorlistPkg, true) | |||
| 336 | + | ||||
| 337 | + | if len(keyringURLs) == 0 || len(mirrorlistURLs) == 0 { | |||
| 338 | + | p.logger.Warn("Failed to build package URLs from mirrors, skipping Chaotic-AUR") | |||
| 339 | + | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (no valid mirrors), skipping") | |||
| 340 | + | return nil // Don't fail, just skip | |||
| 316 | } | 341 | } | ||
| 317 | 342 | ||||
| 343 | + | // Combine all URLs for download | |||
| 344 | + | var urls []string | |||
| 345 | + | urls = append(urls, keyringURLs...) | |||
| 346 | + | urls = append(urls, mirrorlistURLs...) | |||
| 347 | + | ||||
| 348 | + | p.logger.Info("Built package URLs from live mirrors", "keyring_urls", len(keyringURLs), "mirrorlist_urls", len(mirrorlistURLs)) | |||
| 349 | + | ||||
| 350 | + | // Download and install chaotic-keyring and chaotic-mirrorlist | |||
| 351 | + | p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...") | |||
| 352 | + | ||||
| 318 | switch err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, urls...); { | 353 | switch err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, urls...); { | ||
| 319 | case err != nil: | 354 | case err != nil: | ||
| 320 | - | p.logger.Warn("Chaotic-AUR packages unavailable, skipping", "error", err) | 355 | + | p.logger.Warn("Failed to download/install Chaotic packages, skipping Chaotic-AUR", "error", err) |
| 321 | - | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable, skipping (AUR helper will be built from source)") | 356 | + | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (package download failed), skipping") |
| 322 | return nil // IMPORTANT: Exit early, don't add to pacman.conf | 357 | return nil // IMPORTANT: Exit early, don't add to pacman.conf | ||
| 323 | } | 358 | } | ||
| 324 | 359 | ||||
| @@ -351,7 +386,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error | |||||
| 351 | } | 386 | } | ||
| 352 | 387 | ||||
| 353 | // Update package databases | 388 | // Update package databases | ||
| 354 | - | switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "pacman -Sy --noconfirm"); { | 389 | + | switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); { |
| 355 | case err != nil: | 390 | case err != nil: | ||
| 356 | return fmt.Errorf("failed to update package databases: %w", err) | 391 | return fmt.Errorf("failed to update package databases: %w", err) | ||
| 357 | } | 392 | } | ||
A internal/system/mirrorlist.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | package system | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "net/http" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | ChaoticMirrorlistURL = "https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist" | |
| 14 | + | HTTPTimeout = 10 * time.Second | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // MirrorlistEntry represents a parsed mirror entry | |
| 18 | + | type MirrorlistEntry struct { | |
| 19 | + | URL string | |
| 20 | + | Enabled bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // FetchChaoticMirrorlist fetches the official Chaotic-AUR mirrorlist from GitLab | |
| 24 | + | // Returns a list of mirrors with their enabled/disabled status | |
| 25 | + | func FetchChaoticMirrorlist() ([]MirrorlistEntry, error) { | |
| 26 | + | client := &http.Client{Timeout: HTTPTimeout} | |
| 27 | + | ||
| 28 | + | // Fetch mirrorlist from GitLab | |
| 29 | + | resp, err := client.Get(ChaoticMirrorlistURL) | |
| 30 | + | if err != nil { | |
| 31 | + | return nil, fmt.Errorf("network request failed to %s (timeout: %v): %w", ChaoticMirrorlistURL, HTTPTimeout, err) | |
| 32 | + | } | |
| 33 | + | defer resp.Body.Close() | |
| 34 | + | ||
| 35 | + | // Check HTTP response status | |
| 36 | + | if resp.StatusCode != http.StatusOK { | |
| 37 | + | return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Parse the mirrorlist content | |
| 41 | + | mirrors, err := ParseMirrorlist(resp.Body) | |
| 42 | + | if err != nil { | |
| 43 | + | return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | return mirrors, nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // ParseMirrorlist parses a pacman mirrorlist format | |
| 50 | + | // Handles lines like: | |
| 51 | + | // | |
| 52 | + | // Server = https://mirror.example.com/$repo/$arch | |
| 53 | + | // #Server = https://disabled.mirror.com/$repo/$arch | |
| 54 | + | func ParseMirrorlist(r io.Reader) ([]MirrorlistEntry, error) { | |
| 55 | + | var mirrors []MirrorlistEntry | |
| 56 | + | scanner := bufio.NewScanner(r) | |
| 57 | + | ||
| 58 | + | for scanner.Scan() { | |
| 59 | + | line := strings.TrimSpace(scanner.Text()) | |
| 60 | + | ||
| 61 | + | // Skip empty lines and non-Server lines | |
| 62 | + | if line == "" || (!strings.HasPrefix(line, "Server") && !strings.HasPrefix(line, "#Server")) { | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | enabled := !strings.HasPrefix(line, "#") | |
| 67 | + | ||
| 68 | + | // Remove comment prefix if present | |
| 69 | + | if !enabled { | |
| 70 | + | line = strings.TrimPrefix(line, "#") | |
| 71 | + | line = strings.TrimSpace(line) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Parse "Server = URL" format | |
| 75 | + | parts := strings.SplitN(line, "=", 2) | |
| 76 | + | if len(parts) != 2 { | |
| 77 | + | continue | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | url := strings.TrimSpace(parts[1]) | |
| 81 | + | if url == "" { | |
| 82 | + | continue | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | mirrors = append(mirrors, MirrorlistEntry{ | |
| 86 | + | URL: url, | |
| 87 | + | Enabled: enabled, | |
| 88 | + | }) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | if err := scanner.Err(); err != nil { | |
| 92 | + | return nil, fmt.Errorf("error parsing mirrorlist: %w", err) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | if len(mirrors) == 0 { | |
| 96 | + | return nil, fmt.Errorf("no mirrors found in mirrorlist") | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | return mirrors, nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // BuildPackageURLs converts mirror templates to actual package URLs | |
| 103 | + | // Replaces $repo and $arch with actual values, appends package filename | |
| 104 | + | func BuildPackageURLs(mirrors []MirrorlistEntry, packageName string, preferEnabled bool) []string { | |
| 105 | + | const ( | |
| 106 | + | repo = "chaotic-aur" | |
| 107 | + | arch = "x86_64" | |
| 108 | + | ) | |
| 109 | + | ||
| 110 | + | var urls []string | |
| 111 | + | ||
| 112 | + | // First pass: enabled mirrors (if requested) | |
| 113 | + | if preferEnabled { | |
| 114 | + | for _, mirror := range mirrors { | |
| 115 | + | if !mirror.Enabled { | |
| 116 | + | continue | |
| 117 | + | } | |
| 118 | + | url := strings.ReplaceAll(mirror.URL, "$repo", repo) | |
| 119 | + | url = strings.ReplaceAll(url, "$arch", arch) | |
| 120 | + | url = strings.TrimSuffix(url, "/") + "/" + packageName | |
| 121 | + | urls = append(urls, url) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Second pass: all mirrors (fallback) | |
| 126 | + | for _, mirror := range mirrors { | |
| 127 | + | if preferEnabled && mirror.Enabled { | |
| 128 | + | continue // Already added in first pass | |
| 129 | + | } | |
| 130 | + | url := strings.ReplaceAll(mirror.URL, "$repo", repo) | |
| 131 | + | url = strings.ReplaceAll(url, "$arch", arch) | |
| 132 | + | url = strings.TrimSuffix(url, "/") + "/" + packageName | |
| 133 | + | urls = append(urls, url) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | return urls | |
| 137 | + | } | |
| @@ -0,0 +1,137 @@ | |||||
| 1 | + | package system | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "net/http" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | ChaoticMirrorlistURL = "https://gitlab.com/chaotic-aur/pkgbuilds/-/raw/main/chaotic-mirrorlist/mirrorlist" | |||
| 14 | + | HTTPTimeout = 10 * time.Second | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // MirrorlistEntry represents a parsed mirror entry | |||
| 18 | + | type MirrorlistEntry struct { | |||
| 19 | + | URL string | |||
| 20 | + | Enabled bool | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // FetchChaoticMirrorlist fetches the official Chaotic-AUR mirrorlist from GitLab | |||
| 24 | + | // Returns a list of mirrors with their enabled/disabled status | |||
| 25 | + | func FetchChaoticMirrorlist() ([]MirrorlistEntry, error) { | |||
| 26 | + | client := &http.Client{Timeout: HTTPTimeout} | |||
| 27 | + | ||||
| 28 | + | // Fetch mirrorlist from GitLab | |||
| 29 | + | resp, err := client.Get(ChaoticMirrorlistURL) | |||
| 30 | + | if err != nil { | |||
| 31 | + | return nil, fmt.Errorf("network request failed to %s (timeout: %v): %w", ChaoticMirrorlistURL, HTTPTimeout, err) | |||
| 32 | + | } | |||
| 33 | + | defer resp.Body.Close() | |||
| 34 | + | ||||
| 35 | + | // Check HTTP response status | |||
| 36 | + | if resp.StatusCode != http.StatusOK { | |||
| 37 | + | return nil, fmt.Errorf("failed to fetch mirrorlist from %s: HTTP %d %s", ChaoticMirrorlistURL, resp.StatusCode, http.StatusText(resp.StatusCode)) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Parse the mirrorlist content | |||
| 41 | + | mirrors, err := ParseMirrorlist(resp.Body) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return nil, fmt.Errorf("failed to parse mirrorlist from %s: %w", ChaoticMirrorlistURL, err) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | return mirrors, nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // ParseMirrorlist parses a pacman mirrorlist format | |||
| 50 | + | // Handles lines like: | |||
| 51 | + | // | |||
| 52 | + | // Server = https://mirror.example.com/$repo/$arch | |||
| 53 | + | // #Server = https://disabled.mirror.com/$repo/$arch | |||
| 54 | + | func ParseMirrorlist(r io.Reader) ([]MirrorlistEntry, error) { | |||
| 55 | + | var mirrors []MirrorlistEntry | |||
| 56 | + | scanner := bufio.NewScanner(r) | |||
| 57 | + | ||||
| 58 | + | for scanner.Scan() { | |||
| 59 | + | line := strings.TrimSpace(scanner.Text()) | |||
| 60 | + | ||||
| 61 | + | // Skip empty lines and non-Server lines | |||
| 62 | + | if line == "" || (!strings.HasPrefix(line, "Server") && !strings.HasPrefix(line, "#Server")) { | |||
| 63 | + | continue | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | enabled := !strings.HasPrefix(line, "#") | |||
| 67 | + | ||||
| 68 | + | // Remove comment prefix if present | |||
| 69 | + | if !enabled { | |||
| 70 | + | line = strings.TrimPrefix(line, "#") | |||
| 71 | + | line = strings.TrimSpace(line) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Parse "Server = URL" format | |||
| 75 | + | parts := strings.SplitN(line, "=", 2) | |||
| 76 | + | if len(parts) != 2 { | |||
| 77 | + | continue | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | url := strings.TrimSpace(parts[1]) | |||
| 81 | + | if url == "" { | |||
| 82 | + | continue | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | mirrors = append(mirrors, MirrorlistEntry{ | |||
| 86 | + | URL: url, | |||
| 87 | + | Enabled: enabled, | |||
| 88 | + | }) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | if err := scanner.Err(); err != nil { | |||
| 92 | + | return nil, fmt.Errorf("error parsing mirrorlist: %w", err) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | if len(mirrors) == 0 { | |||
| 96 | + | return nil, fmt.Errorf("no mirrors found in mirrorlist") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | return mirrors, nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // BuildPackageURLs converts mirror templates to actual package URLs | |||
| 103 | + | // Replaces $repo and $arch with actual values, appends package filename | |||
| 104 | + | func BuildPackageURLs(mirrors []MirrorlistEntry, packageName string, preferEnabled bool) []string { | |||
| 105 | + | const ( | |||
| 106 | + | repo = "chaotic-aur" | |||
| 107 | + | arch = "x86_64" | |||
| 108 | + | ) | |||
| 109 | + | ||||
| 110 | + | var urls []string | |||
| 111 | + | ||||
| 112 | + | // First pass: enabled mirrors (if requested) | |||
| 113 | + | if preferEnabled { | |||
| 114 | + | for _, mirror := range mirrors { | |||
| 115 | + | if !mirror.Enabled { | |||
| 116 | + | continue | |||
| 117 | + | } | |||
| 118 | + | url := strings.ReplaceAll(mirror.URL, "$repo", repo) | |||
| 119 | + | url = strings.ReplaceAll(url, "$arch", arch) | |||
| 120 | + | url = strings.TrimSuffix(url, "/") + "/" + packageName | |||
| 121 | + | urls = append(urls, url) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // Second pass: all mirrors (fallback) | |||
| 126 | + | for _, mirror := range mirrors { | |||
| 127 | + | if preferEnabled && mirror.Enabled { | |||
| 128 | + | continue // Already added in first pass | |||
| 129 | + | } | |||
| 130 | + | url := strings.ReplaceAll(mirror.URL, "$repo", repo) | |||
| 131 | + | url = strings.ReplaceAll(url, "$arch", arch) | |||
| 132 | + | url = strings.TrimSuffix(url, "/") + "/" + packageName | |||
| 133 | + | urls = append(urls, url) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | return urls | |||
| 137 | + | } | |||
A internal/system/mirrorlist_test.go +287 -0
| @@ -0,0 +1,287 @@ | |||
| 1 | + | package system | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestParseMirrorlist(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | input string | |
| 12 | + | wantCount int | |
| 13 | + | wantErr bool | |
| 14 | + | wantFirst MirrorlistEntry | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "Standard mirrorlist with enabled and disabled mirrors", | |
| 18 | + | input: `# Chaotic-AUR mirrorlist | |
| 19 | + | Server = https://cdn-mirror.chaotic.cx/$repo/$arch | |
| 20 | + | #Server = https://backup.mirror.com/$repo/$arch | |
| 21 | + | Server = https://another.mirror.com/$repo/$arch`, | |
| 22 | + | wantCount: 3, | |
| 23 | + | wantErr: false, | |
| 24 | + | wantFirst: MirrorlistEntry{URL: "https://cdn-mirror.chaotic.cx/$repo/$arch", Enabled: true}, | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | name: "Empty mirrorlist", | |
| 28 | + | input: `# Just comments | |
| 29 | + | # No servers here`, | |
| 30 | + | wantCount: 0, | |
| 31 | + | wantErr: true, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "Mirrorlist with empty lines and whitespace", | |
| 35 | + | input: `# Header | |
| 36 | + | Server = https://mirror1.com/$repo/$arch | |
| 37 | + | ||
| 38 | + | Server = https://mirror2.com/$repo/$arch | |
| 39 | + | ||
| 40 | + | #Server = https://mirror3.com/$repo/$arch`, | |
| 41 | + | wantCount: 3, | |
| 42 | + | wantErr: false, | |
| 43 | + | wantFirst: MirrorlistEntry{URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "All disabled mirrors", | |
| 47 | + | input: `#Server = https://disabled1.com/$repo/$arch | |
| 48 | + | #Server = https://disabled2.com/$repo/$arch`, | |
| 49 | + | wantCount: 2, | |
| 50 | + | wantErr: false, | |
| 51 | + | wantFirst: MirrorlistEntry{URL: "https://disabled1.com/$repo/$arch", Enabled: false}, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "Malformed Server lines (should be skipped)", | |
| 55 | + | input: `Server https://no-equals.com/$repo/$arch | |
| 56 | + | Server = | |
| 57 | + | = https://no-server.com/$repo/$arch | |
| 58 | + | Server = https://valid.com/$repo/$arch`, | |
| 59 | + | wantCount: 1, | |
| 60 | + | wantErr: false, | |
| 61 | + | wantFirst: MirrorlistEntry{URL: "https://valid.com/$repo/$arch", Enabled: true}, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | name: "Mirrors with various whitespace", | |
| 65 | + | input: `Server = https://spaces.com/$repo/$arch | |
| 66 | + | Server=https://nospace.com/$repo/$arch | |
| 67 | + | Server = https://tabs.com/$repo/$arch `, | |
| 68 | + | wantCount: 3, | |
| 69 | + | wantErr: false, | |
| 70 | + | wantFirst: MirrorlistEntry{URL: "https://spaces.com/$repo/$arch", Enabled: true}, | |
| 71 | + | }, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | for _, tt := range tests { | |
| 75 | + | t.Run(tt.name, func(t *testing.T) { | |
| 76 | + | mirrors, err := ParseMirrorlist(strings.NewReader(tt.input)) | |
| 77 | + | ||
| 78 | + | if (err != nil) != tt.wantErr { | |
| 79 | + | t.Errorf("ParseMirrorlist() error = %v, wantErr %v", err, tt.wantErr) | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | if !tt.wantErr && len(mirrors) != tt.wantCount { | |
| 84 | + | t.Errorf("ParseMirrorlist() got %d mirrors, want %d", len(mirrors), tt.wantCount) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | if !tt.wantErr && len(mirrors) > 0 { | |
| 88 | + | if mirrors[0] != tt.wantFirst { | |
| 89 | + | t.Errorf("ParseMirrorlist() first mirror = %+v, want %+v", mirrors[0], tt.wantFirst) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestBuildPackageURLs(t *testing.T) { | |
| 97 | + | tests := []struct { | |
| 98 | + | name string | |
| 99 | + | mirrors []MirrorlistEntry | |
| 100 | + | packageName string | |
| 101 | + | preferEnabled bool | |
| 102 | + | wantCount int | |
| 103 | + | wantURLContains []string | |
| 104 | + | shouldStartWith string | |
| 105 | + | }{ | |
| 106 | + | { | |
| 107 | + | name: "Basic URL building with enabled mirrors preferred", | |
| 108 | + | mirrors: []MirrorlistEntry{ | |
| 109 | + | {URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |
| 110 | + | {URL: "https://mirror2.com/$repo/$arch", Enabled: false}, | |
| 111 | + | {URL: "https://mirror3.com/$repo/$arch", Enabled: true}, | |
| 112 | + | }, | |
| 113 | + | packageName: "test.pkg.tar.zst", | |
| 114 | + | preferEnabled: true, | |
| 115 | + | wantCount: 3, | |
| 116 | + | wantURLContains: []string{ | |
| 117 | + | "https://mirror1.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |
| 118 | + | "https://mirror3.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |
| 119 | + | "https://mirror2.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |
| 120 | + | }, | |
| 121 | + | }, | |
| 122 | + | { | |
| 123 | + | name: "Enabled mirrors should come first", | |
| 124 | + | mirrors: []MirrorlistEntry{ | |
| 125 | + | {URL: "https://disabled.com/$repo/$arch", Enabled: false}, | |
| 126 | + | {URL: "https://enabled1.com/$repo/$arch", Enabled: true}, | |
| 127 | + | {URL: "https://enabled2.com/$repo/$arch", Enabled: true}, | |
| 128 | + | }, | |
| 129 | + | packageName: "pkg.tar.zst", | |
| 130 | + | preferEnabled: true, | |
| 131 | + | wantCount: 3, | |
| 132 | + | shouldStartWith: "https://enabled1.com/chaotic-aur/x86_64/pkg.tar.zst", | |
| 133 | + | }, | |
| 134 | + | { | |
| 135 | + | name: "All mirrors when preferEnabled is false", | |
| 136 | + | mirrors: []MirrorlistEntry{ | |
| 137 | + | {URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |
| 138 | + | {URL: "https://mirror2.com/$repo/$arch", Enabled: false}, | |
| 139 | + | }, | |
| 140 | + | packageName: "test.pkg.tar.zst", | |
| 141 | + | preferEnabled: false, | |
| 142 | + | wantCount: 2, | |
| 143 | + | }, | |
| 144 | + | { | |
| 145 | + | name: "Empty mirrors list", | |
| 146 | + | mirrors: []MirrorlistEntry{}, | |
| 147 | + | packageName: "test.pkg.tar.zst", | |
| 148 | + | preferEnabled: true, | |
| 149 | + | wantCount: 0, | |
| 150 | + | }, | |
| 151 | + | { | |
| 152 | + | name: "Template variable substitution", | |
| 153 | + | mirrors: []MirrorlistEntry{ | |
| 154 | + | {URL: "https://example.com/$repo/$arch", Enabled: true}, | |
| 155 | + | }, | |
| 156 | + | packageName: "chaotic-keyring.pkg.tar.zst", | |
| 157 | + | preferEnabled: true, | |
| 158 | + | wantCount: 1, | |
| 159 | + | wantURLContains: []string{ | |
| 160 | + | "https://example.com/chaotic-aur/x86_64/chaotic-keyring.pkg.tar.zst", | |
| 161 | + | }, | |
| 162 | + | }, | |
| 163 | + | { | |
| 164 | + | name: "URL with trailing slash handling", | |
| 165 | + | mirrors: []MirrorlistEntry{ | |
| 166 | + | {URL: "https://example.com/$repo/$arch/", Enabled: true}, | |
| 167 | + | }, | |
| 168 | + | packageName: "test.pkg.tar.zst", | |
| 169 | + | preferEnabled: true, | |
| 170 | + | wantCount: 1, | |
| 171 | + | wantURLContains: []string{ | |
| 172 | + | "https://example.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |
| 173 | + | }, | |
| 174 | + | }, | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | for _, tt := range tests { | |
| 178 | + | t.Run(tt.name, func(t *testing.T) { | |
| 179 | + | urls := BuildPackageURLs(tt.mirrors, tt.packageName, tt.preferEnabled) | |
| 180 | + | ||
| 181 | + | if len(urls) != tt.wantCount { | |
| 182 | + | t.Errorf("BuildPackageURLs() got %d URLs, want %d", len(urls), tt.wantCount) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | for _, wantURL := range tt.wantURLContains { | |
| 186 | + | found := false | |
| 187 | + | for _, url := range urls { | |
| 188 | + | if url == wantURL { | |
| 189 | + | found = true | |
| 190 | + | break | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | if !found { | |
| 194 | + | t.Errorf("BuildPackageURLs() URL %q not found in result", wantURL) | |
| 195 | + | t.Logf("Got URLs: %v", urls) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | if tt.shouldStartWith != "" && len(urls) > 0 { | |
| 200 | + | if urls[0] != tt.shouldStartWith { | |
| 201 | + | t.Errorf("BuildPackageURLs() first URL = %q, want %q", urls[0], tt.shouldStartWith) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestBuildPackageURLs_EnabledOrderingGuarantee(t *testing.T) { | |
| 209 | + | // Verify that enabled mirrors always come before disabled ones | |
| 210 | + | mirrors := []MirrorlistEntry{ | |
| 211 | + | {URL: "https://d1.com/$repo/$arch", Enabled: false}, | |
| 212 | + | {URL: "https://e1.com/$repo/$arch", Enabled: true}, | |
| 213 | + | {URL: "https://d2.com/$repo/$arch", Enabled: false}, | |
| 214 | + | {URL: "https://e2.com/$repo/$arch", Enabled: true}, | |
| 215 | + | {URL: "https://d3.com/$repo/$arch", Enabled: false}, | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | urls := BuildPackageURLs(mirrors, "test.pkg.tar.zst", true) | |
| 219 | + | ||
| 220 | + | if len(urls) != 5 { | |
| 221 | + | t.Fatalf("Expected 5 URLs, got %d", len(urls)) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // First two should be from enabled mirrors | |
| 225 | + | if !strings.Contains(urls[0], "e1.com") && !strings.Contains(urls[0], "e2.com") { | |
| 226 | + | t.Errorf("First URL should be from enabled mirror, got %s", urls[0]) | |
| 227 | + | } | |
| 228 | + | if !strings.Contains(urls[1], "e1.com") && !strings.Contains(urls[1], "e2.com") { | |
| 229 | + | t.Errorf("Second URL should be from enabled mirror, got %s", urls[1]) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Last three should be from disabled mirrors | |
| 233 | + | for i := 2; i < 5; i++ { | |
| 234 | + | if !strings.Contains(urls[i], ".d") && !strings.Contains(urls[i], "d1") && | |
| 235 | + | !strings.Contains(urls[i], "d2") && !strings.Contains(urls[i], "d3") { | |
| 236 | + | // Check if it contains any disabled mirror domain | |
| 237 | + | isDisabled := false | |
| 238 | + | for _, m := range mirrors { | |
| 239 | + | if !m.Enabled && strings.Contains(urls[i], strings.Split(m.URL, "/")[2]) { | |
| 240 | + | isDisabled = true | |
| 241 | + | break | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | if !isDisabled { | |
| 245 | + | t.Logf("URL at index %d: %s might not be from disabled mirror", i, urls[i]) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func TestParseMirrorlist_SpecialCharacters(t *testing.T) { | |
| 252 | + | // Test with various URL formats that might appear in real mirrorlist | |
| 253 | + | input := ` | |
| 254 | + | Server = https://mirror.example.com:8080/$repo/$arch | |
| 255 | + | Server = http://mirror.example.com/$repo/$arch?token=xyz | |
| 256 | + | Server = https://mirror-example.com/$repo/$arch | |
| 257 | + | Server = https://mirror_example.com/$repo/$arch | |
| 258 | + | Server = https://123.456.789.012/$repo/$arch | |
| 259 | + | ` | |
| 260 | + | ||
| 261 | + | mirrors, err := ParseMirrorlist(strings.NewReader(input)) | |
| 262 | + | if err != nil { | |
| 263 | + | t.Fatalf("ParseMirrorlist() error = %v", err) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | if len(mirrors) != 5 { | |
| 267 | + | t.Errorf("ParseMirrorlist() got %d mirrors, want 5", len(mirrors)) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // Verify all URLs are preserved correctly | |
| 271 | + | urls := []string{ | |
| 272 | + | "https://mirror.example.com:8080/$repo/$arch", | |
| 273 | + | "http://mirror.example.com/$repo/$arch?token=xyz", | |
| 274 | + | "https://mirror-example.com/$repo/$arch", | |
| 275 | + | "https://mirror_example.com/$repo/$arch", | |
| 276 | + | "https://123.456.789.012/$repo/$arch", | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | for i, wantURL := range urls { | |
| 280 | + | if i >= len(mirrors) { | |
| 281 | + | t.Fatalf("Expected mirror at index %d, but only got %d mirrors", i, len(mirrors)) | |
| 282 | + | } | |
| 283 | + | if mirrors[i].URL != wantURL { | |
| 284 | + | t.Errorf("Mirror %d: got %q, want %q", i, mirrors[i].URL, wantURL) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | } | |
| @@ -0,0 +1,287 @@ | |||||
| 1 | + | package system | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestParseMirrorlist(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | input string | |||
| 12 | + | wantCount int | |||
| 13 | + | wantErr bool | |||
| 14 | + | wantFirst MirrorlistEntry | |||
| 15 | + | }{ | |||
| 16 | + | { | |||
| 17 | + | name: "Standard mirrorlist with enabled and disabled mirrors", | |||
| 18 | + | input: `# Chaotic-AUR mirrorlist | |||
| 19 | + | Server = https://cdn-mirror.chaotic.cx/$repo/$arch | |||
| 20 | + | #Server = https://backup.mirror.com/$repo/$arch | |||
| 21 | + | Server = https://another.mirror.com/$repo/$arch`, | |||
| 22 | + | wantCount: 3, | |||
| 23 | + | wantErr: false, | |||
| 24 | + | wantFirst: MirrorlistEntry{URL: "https://cdn-mirror.chaotic.cx/$repo/$arch", Enabled: true}, | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | name: "Empty mirrorlist", | |||
| 28 | + | input: `# Just comments | |||
| 29 | + | # No servers here`, | |||
| 30 | + | wantCount: 0, | |||
| 31 | + | wantErr: true, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "Mirrorlist with empty lines and whitespace", | |||
| 35 | + | input: `# Header | |||
| 36 | + | Server = https://mirror1.com/$repo/$arch | |||
| 37 | + | ||||
| 38 | + | Server = https://mirror2.com/$repo/$arch | |||
| 39 | + | ||||
| 40 | + | #Server = https://mirror3.com/$repo/$arch`, | |||
| 41 | + | wantCount: 3, | |||
| 42 | + | wantErr: false, | |||
| 43 | + | wantFirst: MirrorlistEntry{URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |||
| 44 | + | }, | |||
| 45 | + | { | |||
| 46 | + | name: "All disabled mirrors", | |||
| 47 | + | input: `#Server = https://disabled1.com/$repo/$arch | |||
| 48 | + | #Server = https://disabled2.com/$repo/$arch`, | |||
| 49 | + | wantCount: 2, | |||
| 50 | + | wantErr: false, | |||
| 51 | + | wantFirst: MirrorlistEntry{URL: "https://disabled1.com/$repo/$arch", Enabled: false}, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "Malformed Server lines (should be skipped)", | |||
| 55 | + | input: `Server https://no-equals.com/$repo/$arch | |||
| 56 | + | Server = | |||
| 57 | + | = https://no-server.com/$repo/$arch | |||
| 58 | + | Server = https://valid.com/$repo/$arch`, | |||
| 59 | + | wantCount: 1, | |||
| 60 | + | wantErr: false, | |||
| 61 | + | wantFirst: MirrorlistEntry{URL: "https://valid.com/$repo/$arch", Enabled: true}, | |||
| 62 | + | }, | |||
| 63 | + | { | |||
| 64 | + | name: "Mirrors with various whitespace", | |||
| 65 | + | input: `Server = https://spaces.com/$repo/$arch | |||
| 66 | + | Server=https://nospace.com/$repo/$arch | |||
| 67 | + | Server = https://tabs.com/$repo/$arch `, | |||
| 68 | + | wantCount: 3, | |||
| 69 | + | wantErr: false, | |||
| 70 | + | wantFirst: MirrorlistEntry{URL: "https://spaces.com/$repo/$arch", Enabled: true}, | |||
| 71 | + | }, | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | for _, tt := range tests { | |||
| 75 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 76 | + | mirrors, err := ParseMirrorlist(strings.NewReader(tt.input)) | |||
| 77 | + | ||||
| 78 | + | if (err != nil) != tt.wantErr { | |||
| 79 | + | t.Errorf("ParseMirrorlist() error = %v, wantErr %v", err, tt.wantErr) | |||
| 80 | + | return | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | if !tt.wantErr && len(mirrors) != tt.wantCount { | |||
| 84 | + | t.Errorf("ParseMirrorlist() got %d mirrors, want %d", len(mirrors), tt.wantCount) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | if !tt.wantErr && len(mirrors) > 0 { | |||
| 88 | + | if mirrors[0] != tt.wantFirst { | |||
| 89 | + | t.Errorf("ParseMirrorlist() first mirror = %+v, want %+v", mirrors[0], tt.wantFirst) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | }) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestBuildPackageURLs(t *testing.T) { | |||
| 97 | + | tests := []struct { | |||
| 98 | + | name string | |||
| 99 | + | mirrors []MirrorlistEntry | |||
| 100 | + | packageName string | |||
| 101 | + | preferEnabled bool | |||
| 102 | + | wantCount int | |||
| 103 | + | wantURLContains []string | |||
| 104 | + | shouldStartWith string | |||
| 105 | + | }{ | |||
| 106 | + | { | |||
| 107 | + | name: "Basic URL building with enabled mirrors preferred", | |||
| 108 | + | mirrors: []MirrorlistEntry{ | |||
| 109 | + | {URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |||
| 110 | + | {URL: "https://mirror2.com/$repo/$arch", Enabled: false}, | |||
| 111 | + | {URL: "https://mirror3.com/$repo/$arch", Enabled: true}, | |||
| 112 | + | }, | |||
| 113 | + | packageName: "test.pkg.tar.zst", | |||
| 114 | + | preferEnabled: true, | |||
| 115 | + | wantCount: 3, | |||
| 116 | + | wantURLContains: []string{ | |||
| 117 | + | "https://mirror1.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |||
| 118 | + | "https://mirror3.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |||
| 119 | + | "https://mirror2.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |||
| 120 | + | }, | |||
| 121 | + | }, | |||
| 122 | + | { | |||
| 123 | + | name: "Enabled mirrors should come first", | |||
| 124 | + | mirrors: []MirrorlistEntry{ | |||
| 125 | + | {URL: "https://disabled.com/$repo/$arch", Enabled: false}, | |||
| 126 | + | {URL: "https://enabled1.com/$repo/$arch", Enabled: true}, | |||
| 127 | + | {URL: "https://enabled2.com/$repo/$arch", Enabled: true}, | |||
| 128 | + | }, | |||
| 129 | + | packageName: "pkg.tar.zst", | |||
| 130 | + | preferEnabled: true, | |||
| 131 | + | wantCount: 3, | |||
| 132 | + | shouldStartWith: "https://enabled1.com/chaotic-aur/x86_64/pkg.tar.zst", | |||
| 133 | + | }, | |||
| 134 | + | { | |||
| 135 | + | name: "All mirrors when preferEnabled is false", | |||
| 136 | + | mirrors: []MirrorlistEntry{ | |||
| 137 | + | {URL: "https://mirror1.com/$repo/$arch", Enabled: true}, | |||
| 138 | + | {URL: "https://mirror2.com/$repo/$arch", Enabled: false}, | |||
| 139 | + | }, | |||
| 140 | + | packageName: "test.pkg.tar.zst", | |||
| 141 | + | preferEnabled: false, | |||
| 142 | + | wantCount: 2, | |||
| 143 | + | }, | |||
| 144 | + | { | |||
| 145 | + | name: "Empty mirrors list", | |||
| 146 | + | mirrors: []MirrorlistEntry{}, | |||
| 147 | + | packageName: "test.pkg.tar.zst", | |||
| 148 | + | preferEnabled: true, | |||
| 149 | + | wantCount: 0, | |||
| 150 | + | }, | |||
| 151 | + | { | |||
| 152 | + | name: "Template variable substitution", | |||
| 153 | + | mirrors: []MirrorlistEntry{ | |||
| 154 | + | {URL: "https://example.com/$repo/$arch", Enabled: true}, | |||
| 155 | + | }, | |||
| 156 | + | packageName: "chaotic-keyring.pkg.tar.zst", | |||
| 157 | + | preferEnabled: true, | |||
| 158 | + | wantCount: 1, | |||
| 159 | + | wantURLContains: []string{ | |||
| 160 | + | "https://example.com/chaotic-aur/x86_64/chaotic-keyring.pkg.tar.zst", | |||
| 161 | + | }, | |||
| 162 | + | }, | |||
| 163 | + | { | |||
| 164 | + | name: "URL with trailing slash handling", | |||
| 165 | + | mirrors: []MirrorlistEntry{ | |||
| 166 | + | {URL: "https://example.com/$repo/$arch/", Enabled: true}, | |||
| 167 | + | }, | |||
| 168 | + | packageName: "test.pkg.tar.zst", | |||
| 169 | + | preferEnabled: true, | |||
| 170 | + | wantCount: 1, | |||
| 171 | + | wantURLContains: []string{ | |||
| 172 | + | "https://example.com/chaotic-aur/x86_64/test.pkg.tar.zst", | |||
| 173 | + | }, | |||
| 174 | + | }, | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | for _, tt := range tests { | |||
| 178 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 179 | + | urls := BuildPackageURLs(tt.mirrors, tt.packageName, tt.preferEnabled) | |||
| 180 | + | ||||
| 181 | + | if len(urls) != tt.wantCount { | |||
| 182 | + | t.Errorf("BuildPackageURLs() got %d URLs, want %d", len(urls), tt.wantCount) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | for _, wantURL := range tt.wantURLContains { | |||
| 186 | + | found := false | |||
| 187 | + | for _, url := range urls { | |||
| 188 | + | if url == wantURL { | |||
| 189 | + | found = true | |||
| 190 | + | break | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | if !found { | |||
| 194 | + | t.Errorf("BuildPackageURLs() URL %q not found in result", wantURL) | |||
| 195 | + | t.Logf("Got URLs: %v", urls) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | if tt.shouldStartWith != "" && len(urls) > 0 { | |||
| 200 | + | if urls[0] != tt.shouldStartWith { | |||
| 201 | + | t.Errorf("BuildPackageURLs() first URL = %q, want %q", urls[0], tt.shouldStartWith) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | }) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestBuildPackageURLs_EnabledOrderingGuarantee(t *testing.T) { | |||
| 209 | + | // Verify that enabled mirrors always come before disabled ones | |||
| 210 | + | mirrors := []MirrorlistEntry{ | |||
| 211 | + | {URL: "https://d1.com/$repo/$arch", Enabled: false}, | |||
| 212 | + | {URL: "https://e1.com/$repo/$arch", Enabled: true}, | |||
| 213 | + | {URL: "https://d2.com/$repo/$arch", Enabled: false}, | |||
| 214 | + | {URL: "https://e2.com/$repo/$arch", Enabled: true}, | |||
| 215 | + | {URL: "https://d3.com/$repo/$arch", Enabled: false}, | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | urls := BuildPackageURLs(mirrors, "test.pkg.tar.zst", true) | |||
| 219 | + | ||||
| 220 | + | if len(urls) != 5 { | |||
| 221 | + | t.Fatalf("Expected 5 URLs, got %d", len(urls)) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // First two should be from enabled mirrors | |||
| 225 | + | if !strings.Contains(urls[0], "e1.com") && !strings.Contains(urls[0], "e2.com") { | |||
| 226 | + | t.Errorf("First URL should be from enabled mirror, got %s", urls[0]) | |||
| 227 | + | } | |||
| 228 | + | if !strings.Contains(urls[1], "e1.com") && !strings.Contains(urls[1], "e2.com") { | |||
| 229 | + | t.Errorf("Second URL should be from enabled mirror, got %s", urls[1]) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // Last three should be from disabled mirrors | |||
| 233 | + | for i := 2; i < 5; i++ { | |||
| 234 | + | if !strings.Contains(urls[i], ".d") && !strings.Contains(urls[i], "d1") && | |||
| 235 | + | !strings.Contains(urls[i], "d2") && !strings.Contains(urls[i], "d3") { | |||
| 236 | + | // Check if it contains any disabled mirror domain | |||
| 237 | + | isDisabled := false | |||
| 238 | + | for _, m := range mirrors { | |||
| 239 | + | if !m.Enabled && strings.Contains(urls[i], strings.Split(m.URL, "/")[2]) { | |||
| 240 | + | isDisabled = true | |||
| 241 | + | break | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | if !isDisabled { | |||
| 245 | + | t.Logf("URL at index %d: %s might not be from disabled mirror", i, urls[i]) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func TestParseMirrorlist_SpecialCharacters(t *testing.T) { | |||
| 252 | + | // Test with various URL formats that might appear in real mirrorlist | |||
| 253 | + | input := ` | |||
| 254 | + | Server = https://mirror.example.com:8080/$repo/$arch | |||
| 255 | + | Server = http://mirror.example.com/$repo/$arch?token=xyz | |||
| 256 | + | Server = https://mirror-example.com/$repo/$arch | |||
| 257 | + | Server = https://mirror_example.com/$repo/$arch | |||
| 258 | + | Server = https://123.456.789.012/$repo/$arch | |||
| 259 | + | ` | |||
| 260 | + | ||||
| 261 | + | mirrors, err := ParseMirrorlist(strings.NewReader(input)) | |||
| 262 | + | if err != nil { | |||
| 263 | + | t.Fatalf("ParseMirrorlist() error = %v", err) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | if len(mirrors) != 5 { | |||
| 267 | + | t.Errorf("ParseMirrorlist() got %d mirrors, want 5", len(mirrors)) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // Verify all URLs are preserved correctly | |||
| 271 | + | urls := []string{ | |||
| 272 | + | "https://mirror.example.com:8080/$repo/$arch", | |||
| 273 | + | "http://mirror.example.com/$repo/$arch?token=xyz", | |||
| 274 | + | "https://mirror-example.com/$repo/$arch", | |||
| 275 | + | "https://mirror_example.com/$repo/$arch", | |||
| 276 | + | "https://123.456.789.012/$repo/$arch", | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | for i, wantURL := range urls { | |||
| 280 | + | if i >= len(mirrors) { | |||
| 281 | + | t.Fatalf("Expected mirror at index %d, but only got %d mirrors", i, len(mirrors)) | |||
| 282 | + | } | |||
| 283 | + | if mirrors[i].URL != wantURL { | |||
| 284 | + | t.Errorf("Mirror %d: got %q, want %q", i, mirrors[i].URL, wantURL) | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | } | |||