From 72a07663e7037a527d394382c0bb983c7e82699a Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Tue, 23 Jun 2026 23:08:59 +1000 Subject: [PATCH] feat: add local RPM repository with on-demand repodata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload RPMs to local repos. Metadata is parsed async after upload using cavaliergopher/rpm and stored in rpm_metadata table. Repodata (repomd.xml, primary.xml.gz, filelists.xml.gz, other.xml.gz) is generated on-demand from the DB — nothing stored in S3. - RPM provider implements LocalUploader (validates .rpm extension, stores under Packages/) - RPM provider implements PostUploadHook (async goroutine parses RPM headers, extracts name/version/arch/deps/etc into rpm_metadata) - RPM provider implements LocalIndexer (serves repodata/* paths by querying rpm_metadata and generating XML on the fly) - New provider interfaces: PostUploadHook, BlobReader, MetadataStore, RPMMetadataReader - New rpm_metadata table with JSONB columns for requires/provides/ files/changelogs Tested e2e: upload cowsay RPM → repodata generated → dnf install from local repo --- go.mod | 1 + go.sum | 2 + internal/api/v2/local.go | 17 +- internal/database/postgres.go | 31 +++ internal/database/rpm_metadata.go | 129 ++++++++++ internal/provider/provider.go | 62 +++++ internal/provider/rpm/rpm.go | 387 ++++++++++++++++++++++++++++++ 7 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 internal/database/rpm_metadata.go 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..cfcb84c --- /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[:]) +}