first with full handle over rpm
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
7948368573
commit
274ea454dd
50 changed files with 4309 additions and 0 deletions
195
internal/clone/rpm/cloner.go
Normal file
195
internal/clone/rpm/cloner.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
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)
|
||||
}
|
||||
|
||||
71
internal/clone/rpm/download.go
Normal file
71
internal/clone/rpm/download.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package rpm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func verifyChecksum(data []byte, expected string) error {
|
||||
sum := sha256.Sum256(data)
|
||||
got := hex.EncodeToString(sum[:])
|
||||
if got != expected {
|
||||
return fmt.Errorf("checksum mismatch: got %s, want %s", got, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadAndVerify fetches url into destPath atomically (temp file + rename),
|
||||
// verifies the SHA256 checksum if checksumType is "sha256", and returns bytes written.
|
||||
func DownloadAndVerify(ctx context.Context, httpClient *http.Client, url, destPath, checksumType, expectedChecksum string) (int64, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
tmpPath := destPath + ".tmp"
|
||||
if err := os.MkdirAll(filepath.Dir(tmpPath), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
f, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(f, h), resp.Body)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if checksumType == "sha256" && expectedChecksum != "" {
|
||||
got := hex.EncodeToString(h.Sum(nil))
|
||||
if got != expectedChecksum {
|
||||
os.Remove(tmpPath)
|
||||
return 0, fmt.Errorf("checksum mismatch for %s: got %s, want %s", url, got, expectedChecksum)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, destPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
212
internal/clone/rpm/metadata_gen.go
Normal file
212
internal/clone/rpm/metadata_gen.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
package rpm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const PrimarySourceFile = ".primary-source"
|
||||
const localPrimaryHref = "repodata/primary.xml.gz"
|
||||
|
||||
// filterablePackage captures location for filtering and inner XML for faithful re-emission.
|
||||
type filterablePackage struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Location struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"http://linux.duke.edu/metadata/common location"`
|
||||
Inner string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type filterableMetadata struct {
|
||||
XMLName xml.Name `xml:"http://linux.duke.edu/metadata/common metadata"`
|
||||
Packages []filterablePackage `xml:"http://linux.duke.edu/metadata/common package"`
|
||||
}
|
||||
|
||||
// RegenerateMetadata filters the upstream primary.xml.gz to only include packages
|
||||
// present on disk, writes a new primary.xml.gz, and updates repomd.xml.
|
||||
func RegenerateMetadata(localDir string) error {
|
||||
sourcePath, err := upstreamPrimaryPath(localDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gzData, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read upstream primary: %w", err)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(gzData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open gzip: %w", err)
|
||||
}
|
||||
var meta filterableMetadata
|
||||
if err := xml.NewDecoder(gz).Decode(&meta); err != nil {
|
||||
gz.Close()
|
||||
return fmt.Errorf("parse primary.xml: %w", err)
|
||||
}
|
||||
gz.Close()
|
||||
|
||||
var local []filterablePackage
|
||||
for _, pkg := range meta.Packages {
|
||||
dest := filepath.Join(localDir, filepath.FromSlash(pkg.Location.Href))
|
||||
if _, statErr := os.Stat(dest); statErr == nil {
|
||||
local = append(local, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
var xmlBuf bytes.Buffer
|
||||
xmlBuf.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
||||
fmt.Fprintf(&xmlBuf,
|
||||
`<metadata xmlns="http://linux.duke.edu/metadata/common" xmlns:rpm="http://linux.duke.edu/metadata/rpm" packages="%d">`,
|
||||
len(local))
|
||||
for _, pkg := range local {
|
||||
fmt.Fprintf(&xmlBuf, `<package type="%s">%s</package>`, pkg.Type, pkg.Inner)
|
||||
}
|
||||
xmlBuf.WriteString(`</metadata>`)
|
||||
xmlBytes := xmlBuf.Bytes()
|
||||
|
||||
var gzBuf bytes.Buffer
|
||||
gzw := gzip.NewWriter(&gzBuf)
|
||||
if _, err := gzw.Write(xmlBytes); err != nil {
|
||||
return fmt.Errorf("gzip write: %w", err)
|
||||
}
|
||||
if err := gzw.Close(); err != nil {
|
||||
return fmt.Errorf("gzip close: %w", err)
|
||||
}
|
||||
gzBytes := gzBuf.Bytes()
|
||||
|
||||
openSum := sha256.Sum256(xmlBytes)
|
||||
gzSum := sha256.Sum256(gzBytes)
|
||||
|
||||
outPath := filepath.Join(localDir, localPrimaryHref)
|
||||
if err := atomicWrite(outPath, gzBytes); err != nil {
|
||||
return fmt.Errorf("write primary.xml.gz: %w", err)
|
||||
}
|
||||
|
||||
return updateRepoMD(localDir, gzSum[:], openSum[:], int64(len(gzBytes)), int64(len(xmlBytes)))
|
||||
}
|
||||
|
||||
// upstreamPrimaryPath returns the full path to the original upstream primary.xml.gz,
|
||||
// persisting it in .primary-source so subsequent calls always filter from the full list.
|
||||
func upstreamPrimaryPath(localDir string) (string, error) {
|
||||
markerPath := filepath.Join(localDir, "repodata", PrimarySourceFile)
|
||||
|
||||
if data, err := os.ReadFile(markerPath); err == nil {
|
||||
href := strings.TrimSpace(string(data))
|
||||
full := filepath.Join(localDir, filepath.FromSlash(href))
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
return full, nil
|
||||
}
|
||||
}
|
||||
|
||||
repomdData, err := os.ReadFile(filepath.Join(localDir, "repodata", "repomd.xml"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read repomd.xml: %w", err)
|
||||
}
|
||||
var repomd RepoMD
|
||||
if err := xml.Unmarshal(repomdData, &repomd); err != nil {
|
||||
return "", fmt.Errorf("parse repomd.xml: %w", err)
|
||||
}
|
||||
for _, entry := range repomd.Data {
|
||||
if entry.Type == "primary" && entry.Location.Href != localPrimaryHref {
|
||||
_ = os.WriteFile(markerPath, []byte(entry.Location.Href), 0o644)
|
||||
return filepath.Join(localDir, filepath.FromSlash(entry.Location.Href)), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("upstream primary source not found — repo must be cloned first")
|
||||
}
|
||||
|
||||
func updateRepoMD(localDir string, gzSum, openSum []byte, gzSize, openSize int64) error {
|
||||
repomdPath := filepath.Join(localDir, "repodata", "repomd.xml")
|
||||
repomdData, err := os.ReadFile(repomdPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read repomd.xml: %w", err)
|
||||
}
|
||||
var repomd RepoMD
|
||||
if err := xml.Unmarshal(repomdData, &repomd); err != nil {
|
||||
return fmt.Errorf("parse repomd.xml: %w", err)
|
||||
}
|
||||
for i, entry := range repomd.Data {
|
||||
if entry.Type == "primary" {
|
||||
repomd.Data[i].Location.Href = localPrimaryHref
|
||||
repomd.Data[i].Checksum = RepoMDChecksum{Type: "sha256", Value: hex.EncodeToString(gzSum)}
|
||||
repomd.Data[i].OpenChecksum = RepoMDChecksum{Type: "sha256", Value: hex.EncodeToString(openSum)}
|
||||
repomd.Data[i].Size = gzSize
|
||||
repomd.Data[i].OpenSize = openSize
|
||||
break
|
||||
}
|
||||
}
|
||||
newXML, err := xml.MarshalIndent(repomd, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal repomd.xml: %w", err)
|
||||
}
|
||||
return atomicWrite(repomdPath, append([]byte(xml.Header), newXML...))
|
||||
}
|
||||
|
||||
// InitEmptyRepo creates the directory structure and empty RPM metadata for a new repo.
|
||||
// It is idempotent: if repomd.xml already exists it is left untouched.
|
||||
func InitEmptyRepo(localDir string) error {
|
||||
for _, d := range []string{
|
||||
filepath.Join(localDir, "repodata"),
|
||||
filepath.Join(localDir, "Packages"),
|
||||
} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
repomdPath := filepath.Join(localDir, "repodata", "repomd.xml")
|
||||
if _, err := os.Stat(repomdPath); err == nil {
|
||||
return nil // already initialised
|
||||
}
|
||||
|
||||
emptyXML := []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
`<metadata xmlns="http://linux.duke.edu/metadata/common" xmlns:rpm="http://linux.duke.edu/metadata/rpm" packages="0"></metadata>`)
|
||||
|
||||
var gzBuf bytes.Buffer
|
||||
gzw := gzip.NewWriter(&gzBuf)
|
||||
_, _ = gzw.Write(emptyXML)
|
||||
_ = gzw.Close()
|
||||
gzBytes := gzBuf.Bytes()
|
||||
|
||||
openSum := sha256.Sum256(emptyXML)
|
||||
gzSum := sha256.Sum256(gzBytes)
|
||||
|
||||
if err := atomicWrite(filepath.Join(localDir, localPrimaryHref), gzBytes); err != nil {
|
||||
return fmt.Errorf("write empty primary.xml.gz: %w", err)
|
||||
}
|
||||
|
||||
repomd := RepoMD{
|
||||
Data: []RepoMDEntry{{
|
||||
Type: "primary",
|
||||
Location: RepoMDLocation{Href: localPrimaryHref},
|
||||
Checksum: RepoMDChecksum{Type: "sha256", Value: hex.EncodeToString(gzSum[:])},
|
||||
OpenChecksum: RepoMDChecksum{Type: "sha256", Value: hex.EncodeToString(openSum[:])},
|
||||
Size: int64(len(gzBytes)),
|
||||
OpenSize: int64(len(emptyXML)),
|
||||
}},
|
||||
}
|
||||
newXML, err := xml.MarshalIndent(repomd, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWrite(repomdPath, append([]byte(xml.Header), newXML...))
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
41
internal/clone/rpm/primary.go
Normal file
41
internal/clone/rpm/primary.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package rpm
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
const primaryNS = "http://linux.duke.edu/metadata/common"
|
||||
|
||||
type PrimaryMetadata struct {
|
||||
XMLName xml.Name `xml:"http://linux.duke.edu/metadata/common metadata"`
|
||||
Packages []Package `xml:"http://linux.duke.edu/metadata/common package"`
|
||||
}
|
||||
|
||||
type Package struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Name string `xml:"http://linux.duke.edu/metadata/common name"`
|
||||
Arch string `xml:"http://linux.duke.edu/metadata/common arch"`
|
||||
Version PackageVersion `xml:"http://linux.duke.edu/metadata/common version"`
|
||||
Checksum PackageChecksum `xml:"http://linux.duke.edu/metadata/common checksum"`
|
||||
Location PackageLocation `xml:"http://linux.duke.edu/metadata/common location"`
|
||||
Size PackageSize `xml:"http://linux.duke.edu/metadata/common size"`
|
||||
}
|
||||
|
||||
type PackageVersion struct {
|
||||
Epoch string `xml:"epoch,attr"`
|
||||
Ver string `xml:"ver,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
}
|
||||
|
||||
type PackageChecksum struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type PackageLocation struct {
|
||||
Href string `xml:"href,attr"`
|
||||
}
|
||||
|
||||
type PackageSize struct {
|
||||
Package int64 `xml:"package,attr"`
|
||||
Installed int64 `xml:"installed,attr"`
|
||||
Archive int64 `xml:"archive,attr"`
|
||||
}
|
||||
26
internal/clone/rpm/repomd.go
Normal file
26
internal/clone/rpm/repomd.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package rpm
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
type RepoMD struct {
|
||||
XMLName xml.Name `xml:"repomd"`
|
||||
Data []RepoMDEntry `xml:"data"`
|
||||
}
|
||||
|
||||
type RepoMDEntry struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Location RepoMDLocation `xml:"location"`
|
||||
Checksum RepoMDChecksum `xml:"checksum"`
|
||||
Size int64 `xml:"size"`
|
||||
OpenChecksum RepoMDChecksum `xml:"open-checksum"`
|
||||
OpenSize int64 `xml:"open-size"`
|
||||
}
|
||||
|
||||
type RepoMDLocation struct {
|
||||
Href string `xml:"href,attr"`
|
||||
}
|
||||
|
||||
type RepoMDChecksum struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
143
internal/clone/rpm/scanner.go
Normal file
143
internal/clone/rpm/scanner.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue