//go:build linux || freebsd // +build linux freebsd package metalink import ( "encoding/xml" "fmt" "io" "net/http" "net/url" "os" "sort" "strings" ) // Metalink represents a metalink file (RFC 5854) type Metalink struct { XMLName xml.Name `xml:"metalink"` XMLNS string `xml:"xmlns,attr"` Version string `xml:"version,attr,omitempty"` // Original URL of the metalink OriginURL string `xml:"-"` // Files to download Files []File `xml:"file"` } // File represents a single file in a metalink type File struct { Name string `xml:"name,attr"` Size int64 `xml:"size,attr,omitempty"` // Identifiers MetaURL MetaURL `xml:"metaurl,omitempty"` URLs []URL `xml:"url"` // Hashes for verification Hashes []Hash `xml:"hash"` // Language preferences Lang string `xml:"lang,attr,omitempty"` // Localized names LocalName []LocalName `xml:"localname,omitempty"` // Description Desc string `xml:"desc,omitempty"` // Patches and signatures Patches []Patch `xml:"patch,omitempty"` Signatures []Signature `xml:"signature,omitempty"` } // MetaURL links to another metalink (for mirror selection) type MetaURL struct { URL string `xml:",chardata"` Mediator string `xml:"mediator,attr,omitempty"` Priority int `xml:"priority,attr,omitempty"` Location string `xml:"location,attr,omitempty"` Type string `xml:"type,attr"` } // URL represents a single source for download type URL struct { URL string `xml:",chardata"` Location string `xml:"location,attr,omitempty"` // Geographic location Priority int `xml:"priority,attr,omitempty"` // 1-100, lower = higher priority MaxConn int `xml:"max-connections,attr,omitempty"` Type string `xml:"type,attr,omitempty"` } // Hash represents a checksum for verification type Hash struct { Value string `xml:",chardata"` Type string `xml:"type,attr"` // sha-256, sha-512, md5, etc. } // LocalName localized name of the file type LocalName struct { Name string `xml:",chardata"` Lang string `xml:"xml:lang,attr"` } // Patch information for updating type Patch struct { URL string `xml:"url,attr"` MetaURL string `xml:"metaurl,attr,omitempty"` Size int64 `xml:"size,attr"` Hash Hash `xml:"hash,omitempty"` } // Signature PGP/GPG signature type Signature struct { Value string `xml:",chardata"` Type string `xml:"type,attr"` // pgp, smime, etc. } // Parse parses a metalink file func Parse(r io.Reader) (*Metalink, error) { var ml Metalink decoder := xml.NewDecoder(r) if err := decoder.Decode(&ml); err != nil { return nil, fmt.Errorf("failed to parse metalink: %w", err) } return &ml, nil } // ParseFile parses a metalink from a file func ParseFile(path string) (*Metalink, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to open metalink file: %w", err) } defer f.Close() ml, err := Parse(f) if err != nil { return nil, err } ml.OriginURL = path return ml, nil } // ParseURL fetches and parses a metalink from a URL func ParseURL(urlStr string) (*Metalink, error) { resp, err := http.Get(urlStr) if err != nil { return nil, fmt.Errorf("failed to fetch metalink url: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("metalink url returned status %s", resp.Status) } ml, err := Parse(resp.Body) if err != nil { return nil, err } ml.OriginURL = urlStr return ml, nil } // GetFiles returns all files from the metalink func (m *Metalink) GetFiles() []File { return m.Files } // GetFileByName finds a file by name func (m *Metalink) GetFileByName(name string) *File { for i := range m.Files { if m.Files[i].Name == name { return &m.Files[i] } } return nil } // GetURLs returns all URLs for the file sorted by priority func (f *File) GetURLs() []URL { // Sort by priority (lower = higher priority) sorted := make([]URL, len(f.URLs)) copy(sorted, f.URLs) sort.Slice(sorted, func(i, j int) bool { if sorted[i].Priority == 0 { sorted[i].Priority = 50 // Default priority } if sorted[j].Priority == 0 { sorted[j].Priority = 50 } return sorted[i].Priority < sorted[j].Priority }) return sorted } // GetBestURL returns the URL with the highest priority func (f *File) GetBestURL() *URL { urls := f.GetURLs() if len(urls) == 0 { return nil } return &urls[0] } // GetHash gets a hash of the given type func (f *File) GetHash(hashType string) string { for _, h := range f.Hashes { if strings.EqualFold(h.Type, hashType) { return h.Value } } return "" } // GetSHA256 returns the SHA-256 hash func (f *File) GetSHA256() string { return f.GetHash("sha-256") } // GetSHA512 returns the SHA-512 hash func (f *File) GetSHA512() string { return f.GetHash("sha-512") } // GetMD5 returns the MD5 hash func (f *File) GetMD5() string { return f.GetHash("md5") } // HasHash checks if file has a hash of given type func (f *File) HasHash(hashType string) bool { return f.GetHash(hashType) != "" } // GetSize returns the size of the file func (f *File) GetSize() int64 { return f.Size } // IsValid checks whether the file has at least one URL func (f *File) IsValid() bool { return len(f.URLs) > 0 || f.MetaURL.URL != "" } // GetURLsByLocation returns URLs filtered by location func (f *File) GetURLsByLocation(location string) []URL { var result []URL for _, u := range f.URLs { if u.Location == "" || strings.EqualFold(u.Location, location) { result = append(result, u) } } return result } // GetURLsByType returns URLs of the given type (http, https, ftp, etc.) func (f *File) GetURLsByType(urlType string) []URL { var result []URL for _, u := range f.URLs { if u.Type == "" || strings.EqualFold(u.Type, urlType) { result = append(result, u) } } return result } // GetAllURLs returns all URLs from all files func (m *Metalink) GetAllURLs() []DownloadSource { var sources []DownloadSource for _, file := range m.Files { for _, u := range file.URLs { sources = append(sources, DownloadSource{ FileName: file.Name, URL: u.URL, Priority: u.Priority, Size: file.Size, Hashes: file.Hashes, }) } } return sources } // DownloadSource represents a single source for download type DownloadSource struct { FileName string URL string Priority int Size int64 Hashes []Hash } // GetUniqueURLs returns unique URLs across all files func (m *Metalink) GetUniqueURLs() []string { seen := make(map[string]bool) var result []string for _, file := range m.Files { for _, u := range file.URLs { if !seen[u.URL] { seen[u.URL] = true result = append(result, u.URL) } } } return result } // Validate checks the validity of the metalink func (m *Metalink) Validate() error { if len(m.Files) == 0 { return fmt.Errorf("metalink contains no files") } for i, file := range m.Files { if file.Name == "" { return fmt.Errorf("file %d has no name", i) } if !file.IsValid() { return fmt.Errorf("file %s has no valid urls", file.Name) } } return nil } // GetTotalSize calculates the total size of all files func (m *Metalink) GetTotalSize() int64 { var total int64 for _, f := range m.Files { total += f.Size } return total } // SelectMirrors selects the best mirrors for download func (m *Metalink) SelectMirrors(maxMirrors int, preferredLocation string) []URL { var allURLs []URL for _, file := range m.Files { allURLs = append(allURLs, file.GetURLs()...) } // Sort by priority sort.Slice(allURLs, func(i, j int) bool { pi := allURLs[i].Priority pj := allURLs[j].Priority if pi == 0 { pi = 50 } if pj == 0 { pj = 50 } // Prefer URLs matching preferred location if preferredLocation != "" { iMatch := strings.EqualFold(allURLs[i].Location, preferredLocation) jMatch := strings.EqualFold(allURLs[j].Location, preferredLocation) if iMatch && !jMatch { return true } if !iMatch && jMatch { return false } } return pi < pj }) // Return unique URLs up to maxMirrors seen := make(map[string]bool) var result []URL for _, u := range allURLs { if !seen[u.URL] { seen[u.URL] = true result = append(result, u) if len(result) >= maxMirrors { break } } } return result } // IsMetalinkFile detects whether a file is a metalink by its extension func IsMetalinkFile(path string) bool { lower := strings.ToLower(path) return strings.HasSuffix(lower, ".metalink") || strings.HasSuffix(lower, ".meta4") || strings.HasSuffix(lower, ".metalink4") } // IsMetalinkContentType detects a metalink by its Content-Type header func IsMetalinkContentType(contentType string) bool { ct := strings.ToLower(contentType) return strings.Contains(ct, "application/metalink+xml") || strings.Contains(ct, "application/metalink4+xml") } // ParseURLs parses a metalink from a string (for inline metalinks) func ParseURLs(metalinkXML string) (*Metalink, error) { return Parse(strings.NewReader(metalinkXML)) } // GetPreferredProtocol returns the preferred protocol from URLs func GetPreferredProtocol(urls []URL) string { // Prefer HTTPS > HTTP > FTP protocolPriority := map[string]int{ "https": 1, "http": 2, "ftp": 3, "ftps": 2, } bestProtocol := "" bestPriority := 999 for _, u := range urls { parsed, err := url.Parse(u.URL) if err != nil { continue } prio := protocolPriority[parsed.Scheme] if prio == 0 { prio = 50 // Unknown protocol } if prio < bestPriority { bestPriority = prio bestProtocol = parsed.Scheme } } if bestProtocol == "" { return "https" } return bestProtocol }