Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion cmd/image-builder/repos.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"os"
"path/filepath"

"github.com/osbuild/images/data/repositories"
repos "github.com/osbuild/images/data/repositories"
"github.com/osbuild/images/pkg/reporegistry"
"github.com/osbuild/images/pkg/rpmmd"

"github.com/osbuild/image-builder-cli/internal/dnfparse"
)

// defaultRepoDirs contains the default search paths to look for
Expand Down Expand Up @@ -38,6 +40,40 @@ func parseRepoURLs(repoURLs []string, what string) ([]rpmmd.RepoConfig, error) {
return nil, fmt.Errorf(`scheme missing in %q, please prefix with e.g. file:// or https://`, repoURL)
}

// file:// path to a .repo file: parse as DNF repo file and append each repo
if baseURL.Scheme == "file" && baseURL.Host == "" {
path := baseURL.Path
if path == "" {
return nil, fmt.Errorf("file URL has no path: %q", repoURL)
}
info, err := os.Stat(path)
if err == nil && info.Mode().IsRegular() {
parsed, err := dnfparse.ParseFile(path)
if err != nil {
return nil, fmt.Errorf("parse repo file %q: %w", repoURL, err)
}
for _, repo := range parsed {
name := repo.Name
if name == "" {
name = repo.Id
}
repoConf = append(repoConf, rpmmd.RepoConfig{
Id: fmt.Sprintf("%s-repo-%d-%s", what, i, repo.Id),
Name: name,
BaseURLs: repo.BaseURLs,
CheckGPG: repo.CheckGPG,
CheckRepoGPG: repo.CheckRepoGPG,
GPGKeys: repo.GPGKeys,
SSLCACert: repo.SSLCACert,
SSLClientCert: repo.SSLClientCert,
SSLClientKey: repo.SSLClientKey,
IgnoreSSL: repo.IgnoreSSL,
})
}
continue
}
}

// TODO: to support gpg checking we will need to add signing keys.
// We will eventually add support for our own "repo.json" format
// which is rich enough to contain gpg keys (and more).
Expand Down
53 changes: 53 additions & 0 deletions cmd/image-builder/repos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,59 @@ func TestParseExtraRepoSad(t *testing.T) {
assert.EqualError(t, err, `scheme missing in "/just/a/path", please prefix with e.g. file:// or https://`)
}

func TestParseRepoURLsFileRepo(t *testing.T) {
dir := t.TempDir()
repoPath := filepath.Join(dir, "test.repo")
err := os.WriteFile(repoPath, []byte(`[fedora]
name=Fedora
baseurl=https://download.fedoraproject.org/pub/fedora/linux/releases/42/Everything/x86_64/os/
gpgcheck=1
repo_gpgcheck=0
`), 0644)
require.NoError(t, err)

cfg, err := parseRepoURLs([]string{"file://" + repoPath}, "extra")
require.NoError(t, err)
require.Len(t, cfg, 1)
assert.Equal(t, "extra-repo-0-fedora", cfg[0].Id)
assert.Equal(t, "Fedora", cfg[0].Name)
assert.Equal(t, []string{"https://download.fedoraproject.org/pub/fedora/linux/releases/42/Everything/x86_64/os/"}, cfg[0].BaseURLs)
assert.True(t, cfg[0].CheckGPG != nil && *cfg[0].CheckGPG)
assert.True(t, cfg[0].CheckRepoGPG != nil && !*cfg[0].CheckRepoGPG)
}

func TestParseRepoURLsFileRepoWithGPGKey(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "RPM-GPG-KEY-test")
keyContent := []byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\nxyz\n-----END PGP PUBLIC KEY BLOCK-----")
require.NoError(t, os.WriteFile(keyPath, keyContent, 0644))

repoPath := filepath.Join(dir, "test.repo")
err := os.WriteFile(repoPath, []byte(`[myrepo]
name=My Repo
baseurl=https://example.com/repo
gpgcheck=1
gpgkey=file://`+keyPath+`
`), 0644)
require.NoError(t, err)

cfg, err := parseRepoURLs([]string{"file://" + repoPath}, "extra")
require.NoError(t, err)
require.Len(t, cfg, 1)
require.Len(t, cfg[0].GPGKeys, 1)
assert.Equal(t, string(keyContent), cfg[0].GPGKeys[0])
}

func TestParseRepoURLsFileDirectoryTreatedAsURL(t *testing.T) {
// file:// to a directory is not a .repo file: treat as single base-URL repo
dir := t.TempDir()
cfg, err := parseRepoURLs([]string{"file://" + dir}, "extra")
require.NoError(t, err)
require.Len(t, cfg, 1)
assert.Equal(t, "extra-repo-0", cfg[0].Id)
assert.Equal(t, []string{"file://" + dir}, cfg[0].BaseURLs)
}

func TestNewRepoRegistryImplSmoke(t *testing.T) {
registry, err := newRepoRegistryImpl("", nil)
require.NoError(t, err)
Expand Down
146 changes: 146 additions & 0 deletions internal/dnfparse/dnfparse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Package dnfparse provides a trivial parser for DNF/Yum repository files (.repo).
// It supports: id, name, baseurl, gpgcheck, repo_gpgcheck, gpgkey, sslcacert, sslclientcert, sslclientkey, sslverify.
package dnfparse

import (
"bufio"
"fmt"
"io"
"net/url"
"os"
"strconv"
"strings"
)

// Repo holds the parsed repository configuration for a single [section].
type Repo struct {
Id string
Name string
BaseURLs []string
CheckGPG *bool
CheckRepoGPG *bool
GPGKeys []string
SSLCACert string
SSLClientCert string
SSLClientKey string
// IgnoreSSL corresponds to sslverify=0 (true = ignore SSL verification). sslverify=1 means false.
IgnoreSSL *bool
}

// ParseFile reads a DNF repo file and returns all repository sections
// that have at least one baseurl. Sections without baseurl are skipped.
func ParseFile(path string) ([]Repo, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open repo file: %w", err)
}
defer f.Close()
return Parse(f, path)
}

// Parse reads from r (e.g. an open .repo file) and returns all repository
// sections that have at least one baseurl. The name argument is used in errors.
func Parse(r io.Reader, name string) ([]Repo, error) {
var repos []Repo
var cur *Repo
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Comment or empty
if line == "" || line[0] == '#' || line[0] == ';' {
continue
}
if len(line) >= 2 && line[0] == '[' && line[len(line)-1] == ']' {
// Flush current section if it has baseurls
if cur != nil && len(cur.BaseURLs) > 0 {
repos = append(repos, *cur)
}
id := strings.TrimSpace(line[1 : len(line)-1])
cur = &Repo{Id: id}
continue
}
eq := strings.Index(line, "=")
if eq <= 0 || cur == nil {
continue
}
key := strings.TrimSpace(strings.ToLower(line[:eq]))
val := strings.TrimSpace(line[eq+1:])
// Remove optional quotes around value
if len(val) >= 2 && (val[0] == '"' && val[len(val)-1] == '"' || val[0] == '\'' && val[len(val)-1] == '\'') {
val = val[1 : len(val)-1]
}
switch key {
case "name":
cur.Name = val
case "baseurl":
cur.BaseURLs = append(cur.BaseURLs, val)
case "gpgcheck":
b := parseBool(val)
cur.CheckGPG = &b
case "repo_gpgcheck":
b := parseBool(val)
cur.CheckRepoGPG = &b
case "gpgkey":
cur.GPGKeys = append(cur.GPGKeys, val)
case "sslcacert":
cur.SSLCACert = val
case "sslclientcert":
cur.SSLClientCert = val
case "sslclientkey":
cur.SSLClientKey = val
case "sslverify":
// sslverify=1 -> verify (IgnoreSSL=false), sslverify=0 -> ignore (IgnoreSSL=true)
verify := parseBool(val)
ignoreSSL := !verify
cur.IgnoreSSL = &ignoreSSL
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read %s: %w", name, err)
}
if cur != nil && len(cur.BaseURLs) > 0 {
repos = append(repos, *cur)
}
return repos, nil
}

func parseBool(s string) bool {
s = strings.TrimSpace(strings.ToLower(s))
if s == "1" || s == "yes" || s == "true" {
return true
}
n, err := strconv.ParseInt(s, 10, 64)
if err == nil && n != 0 {
return true
}
return false
}

// GPGKeyContents resolves GPG key URIs and returns the concatenated contents
// of the key files. Only file:// URIs are supported; any other scheme returns an error.
func (r *Repo) GPGKeyContents() ([]byte, error) {
var out []byte
for i, uri := range r.GPGKeys {
u, err := url.Parse(uri)
if err != nil {
return nil, fmt.Errorf("gpgkey[%d] invalid URI %q: %w", i, uri, err)
}
if u.Scheme != "file" {
return nil, fmt.Errorf("gpgkey[%d] only file:// URIs are supported, got %q", i, uri)
}
path := u.Path
if path == "" {
return nil, fmt.Errorf("gpgkey[%d] file URI has no path: %q", i, uri)
}

data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("gpgkey[%d] read %q: %w", i, path, err)
}
if len(out) > 0 {
out = append(out, '\n')
}
out = append(out, data...)
}
return out, nil
}
Loading
Loading