first with full handle over rpm

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-04-25 14:49:14 +02:00
commit 274ea454dd
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
50 changed files with 4309 additions and 0 deletions

View 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)
}

View 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
}

View 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
}

View 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"`
}

View 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"`
}

View 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)
}