Some checks failed
Go package / build (push) Has been cancelled
- 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.
319 lines
11 KiB
Go
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)
|
|
}
|
|
}
|