package rpm import ( "compress/gzip" "context" "encoding/xml" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" ) type NewPackage struct { Name string Version string Arch string Location string Checksum string ChecksumType string Size int64 } type Scanner struct { SourceURL string LocalDir string HTTPClient *http.Client } func NewScanner(sourceURL, localDir string) *Scanner { return &Scanner{ SourceURL: strings.TrimRight(sourceURL, "/"), LocalDir: localDir, HTTPClient: &http.Client{Timeout: 5 * time.Minute}, } } // Scan fetches the remote package list and returns packages not present on disk. func (sc *Scanner) Scan(ctx context.Context) ([]NewPackage, error) { repomd, err := sc.fetchRepoMD(ctx) if err != nil { return nil, fmt.Errorf("fetch repomd.xml: %w", err) } var primaryEntry *RepoMDEntry for i := range repomd.Data { if repomd.Data[i].Type == "primary" { primaryEntry = &repomd.Data[i] break } } if primaryEntry == nil { return nil, fmt.Errorf("no primary entry in repomd.xml") } packages, err := sc.fetchPrimary(ctx, *primaryEntry) if err != nil { return nil, fmt.Errorf("fetch primary.xml: %w", err) } var missing []NewPackage for _, pkg := range packages { localPath := filepath.Join(sc.LocalDir, filepath.FromSlash(pkg.Location.Href)) if _, err := os.Stat(localPath); os.IsNotExist(err) { ver := pkg.Version.Ver + "-" + pkg.Version.Rel if pkg.Version.Epoch != "0" && pkg.Version.Epoch != "" { ver = pkg.Version.Epoch + ":" + ver } missing = append(missing, NewPackage{ Name: pkg.Name, Version: ver, Arch: pkg.Arch, Location: pkg.Location.Href, Checksum: pkg.Checksum.Value, ChecksumType: pkg.Checksum.Type, Size: pkg.Size.Package, }) } } return missing, nil } func (sc *Scanner) fetchRepoMD(ctx context.Context) (*RepoMD, error) { data, err := sc.fetchBytes(ctx, sc.SourceURL+"/repodata/repomd.xml") if err != nil { return nil, err } var repomd RepoMD if err := xml.Unmarshal(data, &repomd); err != nil { return nil, fmt.Errorf("parse repomd.xml: %w", err) } return &repomd, nil } func (sc *Scanner) fetchPrimary(ctx context.Context, entry RepoMDEntry) ([]Package, error) { data, err := sc.fetchBytes(ctx, sc.SourceURL+"/"+entry.Location.Href) if err != nil { return nil, err } if entry.Checksum.Type == "sha256" { if err := verifyChecksum(data, entry.Checksum.Value); err != nil { return nil, fmt.Errorf("primary.xml.gz: %w", err) } } // Save upstream primary to disk so RegenerateMetadata can use it as source. dest := filepath.Join(sc.LocalDir, filepath.FromSlash(entry.Location.Href)) if err := os.MkdirAll(filepath.Dir(dest), 0o755); err == nil { if err := writeFile(dest, data); err == nil { marker := filepath.Join(sc.LocalDir, "repodata", PrimarySourceFile) _ = os.WriteFile(marker, []byte(entry.Location.Href), 0o644) } } gz, err := gzip.NewReader(strings.NewReader(string(data))) if err != nil { return nil, fmt.Errorf("open gzip: %w", err) } defer gz.Close() var primary PrimaryMetadata if err := xml.NewDecoder(gz).Decode(&primary); err != nil { return nil, fmt.Errorf("parse primary.xml: %w", err) } return primary.Packages, nil } func (sc *Scanner) fetchBytes(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := sc.HTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) } return io.ReadAll(resp.Body) }