repo-sync/mirror/rpm_test.go
James Coleman 2889766a20
Some checks failed
Go package / build (push) Has been cancelled
feat(mirror): verify repository OpenPGP signatures
- Reject RPM, Debian, and Arch metadata that fails the configured signature policy before trusting its checksums or package paths.
- Preserve the last verified repository generation when signed metadata rotates inconsistently or required files remain unavailable.
- Let operators pin signer keyrings or retrieve unknown issuers from configured keyservers, including archived GnuPG v1 RSA signatures.
- Gate cached server entry points and package misses until a crawl completes under the active signature policy.
2026-09-01 18:29:37 -05:00

319 lines
11 KiB
Go

package mirror
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/grmrgecko/repo-sync/fetch"
"github.com/grmrgecko/repo-sync/internal/testrepos"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// syncRPMFixture builds a served RPM repository, synchronizes it, and
// returns the fixture directory, repository URL, and local repository path.
func syncRPMFixture(t *testing.T, opts *Options) (string, string, string, map[string][]byte) {
t.Helper()
www := t.TempDir()
repoDir := filepath.Join(www, "repos", "el9")
pkgs := testrepos.BuildRPMRepo(t, repoDir)
srv := testrepos.ServeDir(t, www)
repoURL := srv.URL + "/repos/el9"
opts.Type = RepoRPM
if opts.Destination == "" {
opts.Destination = t.TempDir()
}
if opts.Workers == 0 {
opts.Workers = 2
}
if err := syncOne(context.Background(), repoURL, opts.Type, opts); err != nil {
t.Fatal(err)
}
return repoDir, repoURL, filepath.Join(opts.Destination, "repos", "el9"), pkgs
}
// TestSyncRPM verifies a full synchronization mirrors packages and metadata
// with no staged leftovers.
func TestSyncRPM(t *testing.T) {
_, _, local, pkgs := syncRPMFixture(t, &Options{})
for name, data := range pkgs {
got, err := os.ReadFile(filepath.Join(local, "Packages", name))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, data) {
t.Errorf("package %s content mismatch", name)
}
}
if _, err := os.Stat(filepath.Join(local, "repodata", "repomd.xml")); err != nil {
t.Error("repomd.xml not promoted:", err)
}
if _, err := os.Stat(filepath.Join(local, "repodata", "repomd.xml.asc")); err != nil {
t.Error("repomd.xml.asc not promoted:", err)
}
drpm, err := os.ReadFile(filepath.Join(local, "drpms", "foo-0.9_1.0-1.x86_64.drpm"))
if err != nil {
t.Error("delta package not mirrored:", err)
} else if !bytes.Equal(drpm, testrepos.RPMDelta()) {
t.Error("delta package content mismatch")
}
staged, _ := filepath.Glob(filepath.Join(local, "repodata", "*"+fetch.StagedSuffix))
if len(staged) != 0 {
t.Errorf("staged leftovers remain: %v", staged)
}
}
// TestSyncRPMVerify verifies size-only skipping keeps same-size corruption
// while the verify option repairs it.
func TestSyncRPMVerify(t *testing.T) {
opts := &Options{}
repoDir, repoURL, local, _ := syncRPMFixture(t, opts)
_ = repoDir
// Corrupt a local package without changing its size.
victim := filepath.Join(local, "Packages", "foo-1.0-1.x86_64.rpm")
corrupt := bytes.Repeat([]byte("oof"), 500)
if err := os.WriteFile(victim, corrupt, 0644); err != nil {
t.Fatal(err)
}
// A plain re-run trusts the matching size and keeps the corruption.
if err := syncOne(context.Background(), repoURL, opts.Type, opts); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(victim)
if !bytes.Equal(got, corrupt) {
t.Error("size-only run unexpectedly rewrote the file")
}
// A verify run re-hashes local files and repairs the corruption.
opts.Verify = true
if err := syncOne(context.Background(), repoURL, opts.Type, opts); err != nil {
t.Fatal(err)
}
got, _ = os.ReadFile(victim)
if !bytes.Equal(got, bytes.Repeat([]byte("foo"), 500)) {
t.Error("verify run did not repair the corruption")
}
}
// TestSyncRPMPrune verifies stale files are removed only when pruning is
// enabled.
func TestSyncRPMPrune(t *testing.T) {
opts := &Options{}
_, repoURL, local, _ := syncRPMFixture(t, opts)
stale := filepath.Join(local, "Packages", "stale.rpm")
if err := os.WriteFile(stale, []byte("junk"), 0644); err != nil {
t.Fatal(err)
}
if err := syncOne(context.Background(), repoURL, opts.Type, opts); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(stale); err != nil {
t.Error("file pruned without the prune option")
}
opts.Prune = true
if err := syncOne(context.Background(), repoURL, opts.Type, opts); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(stale); err == nil {
t.Error("stale file survived pruning")
}
}
// TestSyncRPMDroppedSignature verifies a signature the upstream stops
// serving is removed locally instead of lingering beside a new repomd.xml.
func TestSyncRPMDroppedSignature(t *testing.T) {
opts := &Options{}
repoDir, repoURL, local, _ := syncRPMFixture(t, opts)
ascLocal := filepath.Join(local, "repodata", "repomd.xml.asc")
if _, err := os.Stat(ascLocal); err != nil {
t.Fatal("signature not mirrored:", err)
}
if err := os.Remove(filepath.Join(repoDir, "repodata", "repomd.xml.asc")); err != nil {
t.Fatal(err)
}
if err := syncOne(context.Background(), repoURL, opts.Type, opts); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(ascLocal); !os.IsNotExist(err) {
t.Error("stale signature survived after the upstream dropped it")
}
}
// TestSyncRPMMissingKeyForbidden verifies S3's 403 response for a missing
// optional key does not abort synchronization.
func TestSyncRPMMissingKeyForbidden(t *testing.T) {
www := t.TempDir()
repoDir := filepath.Join(www, "repos", "el9")
testrepos.BuildRPMRepo(t, repoDir)
files := http.FileServer(http.Dir(www))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/repos/el9/repodata/repomd.xml.key" {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
files.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
dest := t.TempDir()
opts := &Options{Type: RepoRPM, Destination: dest, Workers: 2}
err := syncOne(context.Background(), srv.URL+"/repos/el9", opts.Type, opts)
require.NoError(t, err)
local := filepath.Join(dest, "repos", "el9", "repodata")
assert.FileExists(t, filepath.Join(local, "repomd.xml"))
assert.FileExists(t, filepath.Join(local, "repomd.xml.asc"))
assert.NoFileExists(t, filepath.Join(local, "repomd.xml.key"))
}
// TestSyncRPMSignedPair verifies repository keys and keyserver retrieval,
// retries a split upstream rotation, and preserves the live pair when the
// upstream remains inconsistent.
func TestSyncRPMSignedPair(t *testing.T) {
www := t.TempDir()
repoDir := filepath.Join(www, "repos", "el9")
testrepos.BuildRPMRepo(t, repoDir)
key := testrepos.NewSigningKey(t)
key.SignRPMRepo(t, repoDir)
repomdPath := filepath.Join(repoDir, "repodata", "repomd.xml")
firstRepomd, err := os.ReadFile(repomdPath)
require.NoError(t, err)
firstSig := key.Sign(t, firstRepomd)
var phase atomic.Int32
var signatureRequests atomic.Int32
secondRepomd := append(append([]byte(nil), firstRepomd...), '\n')
secondSig := key.Sign(t, secondRepomd)
thirdRepomd := append(append([]byte(nil), secondRepomd...), '\n')
files := http.FileServer(http.Dir(www))
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/repos/el9/repodata/repomd.xml":
switch phase.Load() {
case 0:
_, _ = w.Write(firstRepomd)
case 1:
_, _ = w.Write(secondRepomd)
default:
_, _ = w.Write(thirdRepomd)
}
case "/repos/el9/repodata/repomd.xml.asc":
request := signatureRequests.Add(1)
if phase.Load() == 1 && request > 1 {
_, _ = w.Write(secondSig)
return
}
if phase.Load() == 2 {
_, _ = w.Write(secondSig)
return
}
_, _ = w.Write(firstSig)
default:
files.ServeHTTP(w, r)
}
}))
t.Cleanup(upstream.Close)
dest := t.TempDir()
opts := &Options{
Type: RepoRPM,
Destination: dest,
Workers: 2,
SignatureMode: SignatureIfPresent,
}
repoURL := upstream.URL + "/repos/el9"
require.NoError(t, syncOne(context.Background(), repoURL, opts.Type, opts))
local := filepath.Join(dest, "repos", "el9", "repodata")
assert.Equal(t, firstRepomd, requireReadFile(t, filepath.Join(local, "repomd.xml")))
// Remove the adjacent key so the rotation resolves its signer through
// the configured keyserver.
require.NoError(t, os.Remove(filepath.Join(repoDir, "repodata", "repomd.xml.key")))
publicKey := key.PublicKey(t)
otherKey := testrepos.NewSigningKey(t)
otherPublicKey := otherKey.PublicKey(t)
unrelatedKeyserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(otherPublicKey)
}))
t.Cleanup(unrelatedKeyserver.Close)
keyserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "get", r.URL.Query().Get("op"))
assert.NotEmpty(t, r.URL.Query().Get("search"))
_, _ = w.Write(publicKey)
}))
t.Cleanup(keyserver.Close)
opts.Keyservers = []string{unrelatedKeyserver.URL, keyserver.URL}
phase.Store(1)
signatureRequests.Store(0)
require.NoError(t, syncOne(context.Background(), repoURL, opts.Type, opts))
assert.GreaterOrEqual(t, signatureRequests.Load(), int32(2))
assert.Equal(t, secondRepomd, requireReadFile(t, filepath.Join(local, "repomd.xml")))
assert.Equal(t, secondSig, requireReadFile(t, filepath.Join(local, "repomd.xml.asc")))
phase.Store(2)
signatureRequests.Store(0)
err = syncOne(context.Background(), repoURL, opts.Type, opts)
require.Error(t, err)
assert.Equal(t, secondRepomd, requireReadFile(t, filepath.Join(local, "repomd.xml")))
assert.Equal(t, secondSig, requireReadFile(t, filepath.Join(local, "repomd.xml.asc")))
assert.NoFileExists(t, filepath.Join(local, "repomd.xml"+fetch.StagedSuffix))
// Configured keyrings pin the accepted signer, so repository and
// keyserver keys cannot override an operator-managed key.
keyPath := filepath.Join(t.TempDir(), "trusted.asc")
testrepos.WriteFile(t, keyPath, otherPublicKey)
opts.GPGKeys = []string{keyPath}
phase.Store(1)
signatureRequests.Store(1)
err = syncOne(context.Background(), repoURL, opts.Type, opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "not present in the configured GPG keys")
assert.Equal(t, secondRepomd, requireReadFile(t, filepath.Join(local, "repomd.xml")))
}
// requireReadFile reads an asserted fixture result.
func requireReadFile(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile(name)
require.NoError(t, err)
return data
}
// TestSyncRPMChecksumMismatch verifies a package whose served content does
// not match the published checksum fails the synchronization.
func TestSyncRPMChecksumMismatch(t *testing.T) {
www := t.TempDir()
repoDir := filepath.Join(www, "repos", "el9")
testrepos.BuildRPMRepo(t, repoDir)
srv := testrepos.ServeDir(t, www)
// Replace a served package with same-size different content after the
// metadata was generated.
victim := filepath.Join(repoDir, "Packages", "foo-1.0-1.x86_64.rpm")
if err := os.WriteFile(victim, bytes.Repeat([]byte("fox"), 500), 0644); err != nil {
t.Fatal(err)
}
opts := &Options{Type: RepoRPM, Destination: t.TempDir(), Workers: 2}
err := syncOne(context.Background(), srv.URL+"/repos/el9", opts.Type, opts)
if err == nil || !strings.Contains(err.Error(), "checksum") {
t.Errorf("expected checksum mismatch error, got %v", err)
}
}