package rpm import ( "compress/gzip" "context" "encoding/xml" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" ) type Progress struct { File string BytesDone int64 } type ProgressFunc func(p Progress) type Cloner struct { SourceURL string DestDir string HTTPClient *http.Client OnProgress ProgressFunc } func New(sourceURL, destDir string) *Cloner { return &Cloner{ SourceURL: strings.TrimRight(sourceURL, "/"), DestDir: destDir, HTTPClient: &http.Client{Timeout: 30 * time.Minute}, } } func (c *Cloner) Clone(ctx context.Context) error { if err := os.MkdirAll(filepath.Join(c.DestDir, "repodata"), 0o755); err != nil { return fmt.Errorf("create repodata dir: %w", err) } if err := os.MkdirAll(filepath.Join(c.DestDir, "Packages"), 0o755); err != nil { return fmt.Errorf("create Packages dir: %w", err) } repomd, err := c.fetchRepoMD(ctx) if err != nil { return fmt.Errorf("fetch repomd.xml: %w", err) } var primaryEntry *RepoMDEntry for i, entry := range repomd.Data { if entry.Type == "primary" { primaryEntry = &repomd.Data[i] continue } if err := c.downloadMetadataFile(ctx, entry); err != nil { return fmt.Errorf("download metadata %s: %w", entry.Type, err) } } if primaryEntry == nil { return fmt.Errorf("no primary metadata found in repomd.xml") } packages, err := c.fetchPrimary(ctx, *primaryEntry) if err != nil { return fmt.Errorf("fetch primary.xml: %w", err) } for _, pkg := range packages { if err := ctx.Err(); err != nil { return err } if err := c.downloadPackage(ctx, pkg); err != nil { return fmt.Errorf("download package %s: %w", pkg.Name, err) } } return nil } func (c *Cloner) fetchRepoMD(ctx context.Context) (*RepoMD, error) { url := c.SourceURL + "/repodata/repomd.xml" data, err := c.fetchBytes(ctx, url) if err != nil { return nil, err } dest := filepath.Join(c.DestDir, "repodata", "repomd.xml") if err := writeFile(dest, data); 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 (c *Cloner) fetchPrimary(ctx context.Context, entry RepoMDEntry) ([]Package, error) { url := c.SourceURL + "/" + entry.Location.Href data, err := c.fetchBytes(ctx, url) 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) } } dest := filepath.Join(c.DestDir, entry.Location.Href) if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return nil, err } if err := writeFile(dest, data); err != nil { return nil, err } 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 (c *Cloner) downloadMetadataFile(ctx context.Context, entry RepoMDEntry) error { url := c.SourceURL + "/" + entry.Location.Href dest := filepath.Join(c.DestDir, entry.Location.Href) n, err := c.downloadAndVerify(ctx, url, dest, entry.Checksum.Type, entry.Checksum.Value) if err != nil { return err } if c.OnProgress != nil { c.OnProgress(Progress{File: entry.Location.Href, BytesDone: n}) } return nil } func (c *Cloner) downloadPackage(ctx context.Context, pkg Package) error { url := c.SourceURL + "/" + pkg.Location.Href dest := filepath.Join(c.DestDir, pkg.Location.Href) if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return err } n, err := c.downloadAndVerify(ctx, url, dest, pkg.Checksum.Type, pkg.Checksum.Value) if err != nil { return err } if c.OnProgress != nil { c.OnProgress(Progress{File: pkg.Location.Href, BytesDone: n}) } return nil } func (c *Cloner) downloadAndVerify(ctx context.Context, url, destPath, checksumType, expectedChecksum string) (int64, error) { return DownloadAndVerify(ctx, c.HTTPClient, url, destPath, checksumType, expectedChecksum) } func (c *Cloner) 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 := c.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) } func writeFile(path string, data []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } return os.WriteFile(path, data, 0o644) }