diff --git a/go.mod b/go.mod
index fd7c1ea..21d9ea0 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module git.unkin.net/unkin/artifactapi
go 1.25.9
require (
+ github.com/cavaliergopher/rpm v1.3.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-chi/chi/v5 v5.3.0
diff --git a/go.sum b/go.sum
index e31476b..d909271 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cavaliergopher/rpm v1.3.0 h1:UHX46sasX8MesUXXQ+UbkFLUX4eUWTlEcX8jcnRBIgI=
+github.com/cavaliergopher/rpm v1.3.0/go.mod h1:vEumo1vvtrHM1Ov86f6+k8j7zNKOxQfHDCAIcR/36ZI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
diff --git a/internal/api/v2/local.go b/internal/api/v2/local.go
index fe704fe..f1e7ed0 100644
--- a/internal/api/v2/local.go
+++ b/internal/api/v2/local.go
@@ -1,6 +1,7 @@
package v2
import (
+ "context"
"errors"
"fmt"
"io"
@@ -58,14 +59,14 @@ func (h *LocalHandler) upload(w http.ResponseWriter, r *http.Request) {
prov, _ := provider.Get(remote.PackageType)
if uploader, ok := prov.(provider.LocalUploader); ok {
- h.uploadValidated(w, r, remote, filePath, uploader)
+ h.uploadValidated(w, r, remote, filePath, prov, uploader)
return
}
h.uploadGeneric(w, r, remote, filePath)
}
-func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string, uploader provider.LocalUploader) {
+func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string, prov provider.Provider, uploader provider.LocalUploader) {
storagePath, contentType, err := uploader.ValidateUpload(filePath)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -102,6 +103,10 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
return
}
+ if hook, ok := prov.(provider.PostUploadHook); ok {
+ go hook.AfterUpload(context.Background(), remote.Name, storagePath, result.ContentHash, h, h.db)
+ }
+
writeJSON(w, http.StatusCreated, uploader.UploadResponse(storagePath, result.ContentHash, result.SizeBytes))
}
@@ -190,3 +195,11 @@ func (h *LocalHandler) remove(w http.ResponseWriter, r *http.Request) {
func (h *LocalHandler) DB() *database.DB {
return h.db
}
+
+func (h *LocalHandler) Download(ctx context.Context, key string) (io.ReadCloser, int64, error) {
+ reader, info, err := h.store.Download(ctx, key)
+ if err != nil {
+ return nil, 0, err
+ }
+ return reader, info.Size, nil
+}
diff --git a/internal/database/postgres.go b/internal/database/postgres.go
index 54e0b67..1091b1b 100644
--- a/internal/database/postgres.go
+++ b/internal/database/postgres.go
@@ -124,6 +124,37 @@ func (db *DB) migrate() error {
CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at);
ALTER TABLE remotes ADD COLUMN IF NOT EXISTS repo_type TEXT DEFAULT 'remote';
+
+ CREATE TABLE IF NOT EXISTS rpm_metadata (
+ id BIGSERIAL PRIMARY KEY,
+ repo_name TEXT NOT NULL,
+ file_path TEXT NOT NULL,
+ content_hash TEXT NOT NULL,
+ name TEXT NOT NULL,
+ epoch INTEGER DEFAULT 0,
+ version TEXT NOT NULL,
+ release TEXT NOT NULL,
+ arch TEXT NOT NULL,
+ summary TEXT DEFAULT '',
+ description TEXT DEFAULT '',
+ rpm_size BIGINT DEFAULT 0,
+ installed_size BIGINT DEFAULT 0,
+ license TEXT DEFAULT '',
+ vendor TEXT DEFAULT '',
+ build_group TEXT DEFAULT '',
+ build_host TEXT DEFAULT '',
+ source_rpm TEXT DEFAULT '',
+ url TEXT DEFAULT '',
+ packager TEXT DEFAULT '',
+ requires JSONB DEFAULT '[]',
+ provides JSONB DEFAULT '[]',
+ files JSONB DEFAULT '[]',
+ changelogs JSONB DEFAULT '[]',
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE(repo_name, file_path)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_rpm_metadata_repo ON rpm_metadata(repo_name);
`)
return err
}
diff --git a/internal/database/rpm_metadata.go b/internal/database/rpm_metadata.go
new file mode 100644
index 0000000..0e42f8f
--- /dev/null
+++ b/internal/database/rpm_metadata.go
@@ -0,0 +1,129 @@
+package database
+
+import (
+ "context"
+ "encoding/json"
+
+ "git.unkin.net/unkin/artifactapi/internal/provider"
+)
+
+func (db *DB) InsertRPMMetadata(ctx context.Context, meta *provider.RPMMetadata) error {
+ requiresJSON, _ := json.Marshal(meta.Requires)
+ providesJSON, _ := json.Marshal(meta.Provides)
+ filesJSON, _ := json.Marshal(meta.Files)
+ changelogsJSON, _ := json.Marshal(meta.Changelogs)
+
+ _, err := db.Pool.Exec(ctx, `
+ INSERT INTO rpm_metadata (
+ repo_name, file_path, content_hash,
+ name, epoch, version, release, arch,
+ summary, description, rpm_size, installed_size,
+ license, vendor, build_group, build_host, source_rpm, url, packager,
+ requires, provides, files, changelogs
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
+ ON CONFLICT (repo_name, file_path) DO NOTHING
+ `,
+ meta.RepoName, meta.FilePath, meta.ContentHash,
+ meta.Name, meta.Epoch, meta.Version, meta.Release, meta.Arch,
+ meta.Summary, meta.Description, meta.RPMSize, meta.InstalledSize,
+ meta.License, meta.Vendor, meta.Group, meta.BuildHost, meta.SourceRPM, meta.URL, meta.Packager,
+ requiresJSON, providesJSON, filesJSON, changelogsJSON,
+ )
+ return err
+}
+
+type RPMMetadataRow struct {
+ RepoName string
+ FilePath string
+ ContentHash string
+ Name string
+ Epoch int
+ Version string
+ Release string
+ Arch string
+ Summary string
+ Description string
+ RPMSize int64
+ InstalledSize int64
+ License string
+ Vendor string
+ Group string
+ BuildHost string
+ SourceRPM string
+ URL string
+ Packager string
+ Requires json.RawMessage
+ Provides json.RawMessage
+ Files json.RawMessage
+ Changelogs json.RawMessage
+}
+
+func (db *DB) ListRPMMetadataEntries(ctx context.Context, repoName string) ([]provider.RPMMetadata, error) {
+ rows, err := db.ListRPMMetadata(ctx, repoName)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]provider.RPMMetadata, len(rows))
+ for i, r := range rows {
+ meta := provider.RPMMetadata{
+ RepoName: r.RepoName,
+ FilePath: r.FilePath,
+ ContentHash: r.ContentHash,
+ Name: r.Name,
+ Epoch: r.Epoch,
+ Version: r.Version,
+ Release: r.Release,
+ Arch: r.Arch,
+ Summary: r.Summary,
+ Description: r.Description,
+ RPMSize: r.RPMSize,
+ InstalledSize: r.InstalledSize,
+ License: r.License,
+ Vendor: r.Vendor,
+ Group: r.Group,
+ BuildHost: r.BuildHost,
+ SourceRPM: r.SourceRPM,
+ URL: r.URL,
+ Packager: r.Packager,
+ }
+ json.Unmarshal(r.Requires, &meta.Requires)
+ json.Unmarshal(r.Provides, &meta.Provides)
+ json.Unmarshal(r.Files, &meta.Files)
+ json.Unmarshal(r.Changelogs, &meta.Changelogs)
+ result[i] = meta
+ }
+ return result, nil
+}
+
+func (db *DB) ListRPMMetadata(ctx context.Context, repoName string) ([]RPMMetadataRow, error) {
+ rows, err := db.Pool.Query(ctx, `
+ SELECT repo_name, file_path, content_hash,
+ name, epoch, version, release, arch,
+ summary, description, rpm_size, installed_size,
+ license, vendor, build_group, build_host, source_rpm, url, packager,
+ requires, provides, files, changelogs
+ FROM rpm_metadata
+ WHERE repo_name = $1
+ ORDER BY name, epoch, version, release, arch
+ `, repoName)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []RPMMetadataRow
+ for rows.Next() {
+ var r RPMMetadataRow
+ if err := rows.Scan(
+ &r.RepoName, &r.FilePath, &r.ContentHash,
+ &r.Name, &r.Epoch, &r.Version, &r.Release, &r.Arch,
+ &r.Summary, &r.Description, &r.RPMSize, &r.InstalledSize,
+ &r.License, &r.Vendor, &r.Group, &r.BuildHost, &r.SourceRPM, &r.URL, &r.Packager,
+ &r.Requires, &r.Provides, &r.Files, &r.Changelogs,
+ ); err != nil {
+ return nil, err
+ }
+ result = append(result, r)
+ }
+ return result, rows.Err()
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 27d6358..fc628b6 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -3,6 +3,7 @@ package provider
import (
"context"
"fmt"
+ "io"
"net/http"
"git.unkin.net/unkin/artifactapi/pkg/models"
@@ -44,6 +45,67 @@ type LocalIndexer interface {
GenerateLocalIndex(ctx context.Context, files FileStore, repoName, path string) ([]byte, error)
}
+type BlobReader interface {
+ Download(ctx context.Context, key string) (io.ReadCloser, int64, error)
+}
+
+type PostUploadHook interface {
+ AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs BlobReader, db MetadataStore)
+}
+
+type MetadataStore interface {
+ InsertRPMMetadata(ctx context.Context, meta *RPMMetadata) error
+}
+
+type RPMMetadataReader interface {
+ ListRPMMetadataEntries(ctx context.Context, repoName string) ([]RPMMetadata, error)
+}
+
+type RPMMetadata struct {
+ RepoName string
+ FilePath string
+ ContentHash string
+ Name string
+ Epoch int
+ Version string
+ Release string
+ Arch string
+ Summary string
+ Description string
+ RPMSize int64
+ InstalledSize int64
+ License string
+ Vendor string
+ Group string
+ BuildHost string
+ SourceRPM string
+ URL string
+ Packager string
+ Requires []RPMDep
+ Provides []RPMDep
+ Files []RPMFile
+ Changelogs []RPMChangelog
+}
+
+type RPMDep struct {
+ Name string `json:"name"`
+ Flags string `json:"flags,omitempty"`
+ Epoch string `json:"epoch,omitempty"`
+ Version string `json:"version,omitempty"`
+ Release string `json:"release,omitempty"`
+}
+
+type RPMFile struct {
+ Path string `json:"path"`
+ Type string `json:"type,omitempty"`
+}
+
+type RPMChangelog struct {
+ Author string `json:"author"`
+ Date int64 `json:"date"`
+ Text string `json:"text"`
+}
+
type IndexMerger interface {
MergeIndexes(members []MemberIndex, proxyBaseURL string) ([]byte, error)
}
diff --git a/internal/provider/rpm/rpm.go b/internal/provider/rpm/rpm.go
index 511460c..3860ac1 100644
--- a/internal/provider/rpm/rpm.go
+++ b/internal/provider/rpm/rpm.go
@@ -1,13 +1,24 @@
package rpm
import (
+ "bytes"
+ "compress/gzip"
"context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/xml"
+ "fmt"
+ "log/slog"
"net/http"
"regexp"
"strings"
+ "time"
+
+ rpmlib "github.com/cavaliergopher/rpm"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
+ "git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
@@ -55,3 +66,379 @@ func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte,
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
+
+func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) {
+ filename := filePath
+ if idx := strings.LastIndex(filePath, "/"); idx >= 0 {
+ filename = filePath[idx+1:]
+ }
+
+ if !strings.HasSuffix(strings.ToLower(filename), ".rpm") {
+ return "", "", fmt.Errorf("file must be an .rpm package")
+ }
+
+ return "Packages/" + filename, "application/x-rpm", nil
+}
+
+func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any {
+ filename := strings.TrimPrefix(storagePath, "Packages/")
+ return map[string]any{
+ "filename": filename,
+ "content_hash": contentHash,
+ "size_bytes": sizeBytes,
+ }
+}
+
+func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs provider.BlobReader, db provider.MetadataStore) {
+ s3Key := storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:"))
+
+ reader, blobSize, err := blobs.Download(ctx, s3Key)
+ if err != nil {
+ slog.Error("rpm metadata: download failed", "repo", repoName, "path", storagePath, "error", err)
+ return
+ }
+ defer reader.Close()
+
+ pkg, err := rpmlib.Read(reader)
+ if err != nil {
+ slog.Error("rpm metadata: parse failed", "repo", repoName, "path", storagePath, "error", err)
+ return
+ }
+
+ meta := &provider.RPMMetadata{
+ RepoName: repoName,
+ FilePath: storagePath,
+ ContentHash: contentHash,
+ Name: pkg.Name(),
+ Epoch: pkg.Epoch(),
+ Version: pkg.Version(),
+ Release: pkg.Release(),
+ Arch: pkg.Architecture(),
+ Summary: pkg.Summary(),
+ Description: pkg.Description(),
+ RPMSize: blobSize,
+ InstalledSize: int64(pkg.Size()),
+ License: pkg.License(),
+ Vendor: pkg.Vendor(),
+ Group: firstGroup(pkg.Groups()),
+ BuildHost: pkg.BuildHost(),
+ SourceRPM: pkg.SourceRPM(),
+ URL: pkg.URL(),
+ Packager: pkg.Packager(),
+ }
+
+ for _, req := range pkg.Requires() {
+ meta.Requires = append(meta.Requires, rpmDepFromEntry(req))
+ }
+ for _, prov := range pkg.Provides() {
+ meta.Provides = append(meta.Provides, rpmDepFromEntry(prov))
+ }
+
+ if meta.Requires == nil {
+ meta.Requires = []provider.RPMDep{}
+ }
+ if meta.Provides == nil {
+ meta.Provides = []provider.RPMDep{}
+ }
+ meta.Files = []provider.RPMFile{}
+ meta.Changelogs = []provider.RPMChangelog{}
+
+ if err := db.InsertRPMMetadata(ctx, meta); err != nil {
+ slog.Error("rpm metadata: insert failed", "repo", repoName, "path", storagePath, "error", err)
+ return
+ }
+
+ slog.Info("rpm metadata: parsed", "repo", repoName, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
+}
+
+func rpmDepFromEntry(e rpmlib.Dependency) provider.RPMDep {
+ dep := provider.RPMDep{Name: e.Name()}
+ if e.Flags() != 0 {
+ dep.Flags = rpmFlagString(e.Flags())
+ dep.Version = e.Version()
+ dep.Release = e.Release()
+ if e.Epoch() > 0 {
+ dep.Epoch = fmt.Sprintf("%d", e.Epoch())
+ }
+ }
+ return dep
+}
+
+func rpmFlagString(f int) string {
+ switch {
+ case f&0x08 != 0 && f&0x04 != 0:
+ return "GE"
+ case f&0x02 != 0 && f&0x04 != 0:
+ return "LE"
+ case f&0x08 != 0:
+ return "GT"
+ case f&0x02 != 0:
+ return "LT"
+ case f&0x04 != 0:
+ return "EQ"
+ default:
+ return ""
+ }
+}
+
+func firstGroup(groups []string) string {
+ if len(groups) > 0 {
+ return groups[0]
+ }
+ return "Unspecified"
+}
+
+func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
+ if !strings.HasPrefix(path, "repodata/") {
+ return false
+ }
+
+ rpmReader, ok := files.(provider.RPMMetadataReader)
+ if !ok {
+ http.Error(w, "rpm metadata not available", http.StatusInternalServerError)
+ return true
+ }
+
+ tail := strings.TrimPrefix(path, "repodata/")
+
+ switch {
+ case tail == "repomd.xml":
+ p.serveRepomd(w, r, rpmReader, repoName)
+ case strings.HasSuffix(tail, "-primary.xml.gz"):
+ p.servePrimary(w, r, rpmReader, repoName)
+ case strings.HasSuffix(tail, "-filelists.xml.gz"):
+ p.serveFilelists(w, r, rpmReader, repoName)
+ case strings.HasSuffix(tail, "-other.xml.gz"):
+ p.serveOther(w, r, rpmReader, repoName)
+ default:
+ http.Error(w, "not found", http.StatusNotFound)
+ }
+ return true
+}
+
+func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
+ return nil, fmt.Errorf("rpm local index generation for virtual repos not supported")
+}
+
+func (p *Provider) serveRepomd(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
+ metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ primary := generatePrimaryXMLGZ(metas)
+ filelists := generateFilelistsXMLGZ(metas)
+ other := generateOtherXMLGZ(metas)
+
+ primaryHash := sha256Hex(primary)
+ filelistsHash := sha256Hex(filelists)
+ otherHash := sha256Hex(other)
+
+ repomd := generateRepomd(primaryHash, len(primary), filelistsHash, len(filelists), otherHash, len(other))
+
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+ w.Write(repomd)
+}
+
+func (p *Provider) servePrimary(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
+ metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/gzip")
+ w.WriteHeader(http.StatusOK)
+ w.Write(generatePrimaryXMLGZ(metas))
+}
+
+func (p *Provider) serveFilelists(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
+ metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/gzip")
+ w.WriteHeader(http.StatusOK)
+ w.Write(generateFilelistsXMLGZ(metas))
+}
+
+func (p *Provider) serveOther(w http.ResponseWriter, r *http.Request, reader provider.RPMMetadataReader, repoName string) {
+ metas, err := reader.ListRPMMetadataEntries(r.Context(), repoName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/gzip")
+ w.WriteHeader(http.StatusOK)
+ w.Write(generateOtherXMLGZ(metas))
+}
+
+func generateRepomd(primaryHash string, primarySize int, filelistsHash string, filelistsSize int, otherHash string, otherSize int) []byte {
+ ts := fmt.Sprintf("%d", time.Now().Unix())
+ var b bytes.Buffer
+ b.WriteString(xml.Header)
+ b.WriteString(`` + "\n")
+ fmt.Fprintf(&b, " %s\n", ts)
+
+ writeRepomdData(&b, "primary", primaryHash, primarySize, ts)
+ writeRepomdData(&b, "filelists", filelistsHash, filelistsSize, ts)
+ writeRepomdData(&b, "other", otherHash, otherSize, ts)
+
+ b.WriteString("\n")
+ return b.Bytes()
+}
+
+func writeRepomdData(b *bytes.Buffer, dtype, hash string, size int, ts string) {
+ fmt.Fprintf(b, " \n", dtype)
+ fmt.Fprintf(b, " %s\n", hash)
+ fmt.Fprintf(b, " \n", hash, dtype)
+ fmt.Fprintf(b, " %s\n", ts)
+ fmt.Fprintf(b, " %d\n", size)
+ fmt.Fprintf(b, " \n")
+}
+
+func generatePrimaryXMLGZ(metas []provider.RPMMetadata) []byte {
+ var xmlBuf bytes.Buffer
+ xmlBuf.WriteString(xml.Header)
+ fmt.Fprintf(&xmlBuf, "\n", len(metas))
+
+ for _, m := range metas {
+ pkgHash := strings.TrimPrefix(m.ContentHash, "sha256:")
+ fmt.Fprintf(&xmlBuf, "\n")
+ fmt.Fprintf(&xmlBuf, " %s\n", xmlEscape(m.Name))
+ fmt.Fprintf(&xmlBuf, " %s\n", xmlEscape(m.Arch))
+ fmt.Fprintf(&xmlBuf, " \n", m.Epoch, xmlEscape(m.Version), xmlEscape(m.Release))
+ fmt.Fprintf(&xmlBuf, " %s\n", pkgHash)
+ fmt.Fprintf(&xmlBuf, " %s\n", xmlEscape(m.Summary))
+ fmt.Fprintf(&xmlBuf, " %s\n", xmlEscape(m.Description))
+ if m.Packager != "" {
+ fmt.Fprintf(&xmlBuf, " %s\n", xmlEscape(m.Packager))
+ }
+ if m.URL != "" {
+ fmt.Fprintf(&xmlBuf, " %s\n", xmlEscape(m.URL))
+ }
+ fmt.Fprintf(&xmlBuf, " \n")
+ }
+ xmlBuf.WriteString("\n")
+
+ return gzipBytes(xmlBuf.Bytes())
+}
+
+func generateFilelistsXMLGZ(metas []provider.RPMMetadata) []byte {
+ var xmlBuf bytes.Buffer
+ xmlBuf.WriteString(xml.Header)
+ fmt.Fprintf(&xmlBuf, "\n", len(metas))
+
+ for _, m := range metas {
+ pkgHash := strings.TrimPrefix(m.ContentHash, "sha256:")
+ fmt.Fprintf(&xmlBuf, "\n", pkgHash, xmlEscape(m.Name), xmlEscape(m.Arch))
+ fmt.Fprintf(&xmlBuf, " \n", m.Epoch, xmlEscape(m.Version), xmlEscape(m.Release))
+ for _, f := range m.Files {
+ if f.Type != "" {
+ fmt.Fprintf(&xmlBuf, " %s\n", f.Type, xmlEscape(f.Path))
+ } else {
+ fmt.Fprintf(&xmlBuf, " %s\n", xmlEscape(f.Path))
+ }
+ }
+ xmlBuf.WriteString("\n")
+ }
+ xmlBuf.WriteString("\n")
+
+ return gzipBytes(xmlBuf.Bytes())
+}
+
+func generateOtherXMLGZ(metas []provider.RPMMetadata) []byte {
+ var xmlBuf bytes.Buffer
+ xmlBuf.WriteString(xml.Header)
+ fmt.Fprintf(&xmlBuf, "\n", len(metas))
+
+ for _, m := range metas {
+ pkgHash := strings.TrimPrefix(m.ContentHash, "sha256:")
+ fmt.Fprintf(&xmlBuf, "\n", pkgHash, xmlEscape(m.Name), xmlEscape(m.Arch))
+ fmt.Fprintf(&xmlBuf, " \n", m.Epoch, xmlEscape(m.Version), xmlEscape(m.Release))
+ for _, cl := range m.Changelogs {
+ fmt.Fprintf(&xmlBuf, " %s\n",
+ xmlEscape(cl.Author), cl.Date, xmlEscape(cl.Text))
+ }
+ xmlBuf.WriteString("\n")
+ }
+ xmlBuf.WriteString("\n")
+
+ return gzipBytes(xmlBuf.Bytes())
+}
+
+func writeRPMEntry(b *bytes.Buffer, d provider.RPMDep) {
+ if d.Flags != "" {
+ fmt.Fprintf(b, " \n")
+ } else {
+ fmt.Fprintf(b, " \n", xmlEscape(d.Name))
+ }
+}
+
+func xmlEscape(s string) string {
+ var b bytes.Buffer
+ xml.EscapeText(&b, []byte(s))
+ return b.String()
+}
+
+func gzipBytes(data []byte) []byte {
+ var buf bytes.Buffer
+ gz := gzip.NewWriter(&buf)
+ gz.Write(data)
+ gz.Close()
+ return buf.Bytes()
+}
+
+func sha256Hex(data []byte) string {
+ h := sha256.Sum256(data)
+ return hex.EncodeToString(h[:])
+}