feat(settings): add PKGBUILD repository config type and validation

Add the PkgbuildRepo type, Configuration.PkgbuildRepos field, and
NormalizePkgbuildRepos to validate repo names and URLs against the
security model: PKGBUILD repos can mask the AUR, so URLs must use a
non-tamperable transport and names must stay within the cache directory.

Wire NormalizePkgbuildRepos into main so config errors surface at
startup. Nothing reads PkgbuildRepos yet; it is only parsed and
validated at this layer.
This commit is contained in:
Jo Garnier committed 2026-08-07 16:09:42 +09:00
1 parent 0043665f62
commit 91b3a7a1df
5 files changed
+271

No files matched your search

+7
View File
@@ -95,6 +95,13 @@ func main() {
defer luaEngine.Close()
}
if err = cfg.NormalizePkgbuildRepos(); err != nil {
fallbackLog.Errorln(err)
ret = 1
return
}
cmdArgs := parser.MakeArguments()
// Parse command line
+7
View File
@@ -63,6 +63,13 @@
---@field debug boolean Enable debug logging and local init.lua lookup convenience.
---@field rpc boolean Use AUR RPC for dependency/query operations.
---@field double_confirm boolean Ask for confirmation before and after builds during upgrades.
---@field pkgbuild_repos table<string, yay.PkgbuildRepo> Named PKGBUILD repositories that take priority over the AUR. init.lua only.
-- PKGBUILD repositories: yay.opt.pkgbuild_repos
---@class yay.PkgbuildRepo
---@field url string Repo location, following the makepkg source convention: an https git URL, a git+file:// local git repo, or a file:// local directory used in place.
---@field depth? integer Recursive PKGBUILD scan depth (default 3).
-- Logging: yay.log
+4
View File
@@ -71,6 +71,10 @@ type Configuration struct {
UseRPC bool `json:"rpc" lua:"rpc"`
DoubleConfirm bool `json:"doubleconfirm" lua:"double_confirm"` // confirm install before and after build
// PkgbuildRepos is configured only via init.lua (yay.opt.pkgbuild_repos),
// never persisted to config.json.
PkgbuildRepos []PkgbuildRepo `json:"-" lua:"pkgbuild_repos"`
CompletionPath string `json:"-" lua:"-"`
VCSFilePath string `json:"-" lua:"-"`
// ConfigPath string `json:"-"`
+106
View File
@@ -0,0 +1,106 @@
package settings
import (
"fmt"
"path/filepath"
"slices"
"strings"
)
// DefaultPkgbuildRepoDepth is the recursive PKGBUILD scan depth used when a
// repo does not set one explicitly.
const DefaultPkgbuildRepoDepth = 3
// PkgbuildRepo is a user-configured PKGBUILD repository. Packages found in a
// PKGBUILD repository take priority over the AUR, so a repo can mask an AUR
// package. Repositories are configured only through init.lua via
// yay.opt.pkgbuild_repos.
type PkgbuildRepo struct {
// Name identifies the repo; it comes from the pkgbuild_repos table key.
Name string `json:"name" lua:"name"`
// URL locates the repo, following the makepkg source convention: an https
// git URL, a git+file:// local git repo, or a file:// local directory used
// in place.
URL string `json:"url" lua:"url"`
// Depth is how many directory levels deep yay scans for PKGBUILDs.
Depth int `json:"depth" lua:"depth"`
}
// NormalizePkgbuildRepos validates repository names and URLs, rejects duplicate
// names, and fills in default depths.
func (c *Configuration) NormalizePkgbuildRepos() error {
seen := make(map[string]struct{}, len(c.PkgbuildRepos))
for i := range c.PkgbuildRepos {
name := c.PkgbuildRepos[i].Name
if name == "" || name == "." || strings.HasPrefix(name, "-") || !filepath.IsLocal(name) || filepath.Base(name) != name {
return fmt.Errorf("invalid PKGBUILD repository name %q", name)
}
// Names become directory names under the cache dir, so two repos sharing
// one would fight over the same checkout.
if _, dup := seen[name]; dup {
return fmt.Errorf("duplicate PKGBUILD repository name %q", name)
}
seen[name] = struct{}{}
if err := validatePkgbuildRepoURL(name, c.PkgbuildRepos[i].URL); err != nil {
return err
}
if c.PkgbuildRepos[i].Depth <= 0 {
c.PkgbuildRepos[i].Depth = DefaultPkgbuildRepoDepth
}
}
return nil
}
// insecurePkgbuildRepoSchemes are unauthenticated transports. A PKGBUILD repo
// masks the AUR, so fetching one over a tamperable transport would hand an
// on-path attacker arbitrary code execution at build time.
var insecurePkgbuildRepoSchemes = []string{"http://", "git://"}
// PkgbuildRepoGitSchemes are the URL schemes a PKGBUILD repository is cloned
// from (after any "git+" prefix is stripped). Anything else is a local
// directory used in place. Validation and location resolution share this table
// so a scheme can never pass one and be reinterpreted by the other.
var PkgbuildRepoGitSchemes = []string{"https://", "ssh://", "file://"}
// validatePkgbuildRepoURL rejects URLs that Resolve would otherwise silently
// reinterpret as a local filesystem path.
func validatePkgbuildRepoURL(name, url string) error {
if url == "" {
return fmt.Errorf("PKGBUILD repository %q has no url", name)
}
// The URL is passed to git as a positional argument.
if strings.HasPrefix(url, "-") {
return fmt.Errorf("PKGBUILD repository %q has an invalid url %q", name, url)
}
bare := strings.TrimPrefix(url, "git+")
for _, scheme := range insecurePkgbuildRepoSchemes {
if strings.HasPrefix(bare, scheme) {
return fmt.Errorf("PKGBUILD repository %q uses insecure protocol %s in %q",
name, strings.TrimSuffix(scheme, "://"), url)
}
}
if scheme, _, ok := strings.Cut(bare, "://"); ok {
if slices.Contains(PkgbuildRepoGitSchemes, scheme+"://") {
return nil
}
return fmt.Errorf("PKGBUILD repository %q uses unsupported protocol %s in %q", name, scheme, url)
}
// A bare path is used in place (or cloned from, when it ends in .git).
// Requiring it to be absolute stops a repo from resolving against whatever
// directory yay happens to be run from.
if !filepath.IsAbs(url) && !strings.Contains(url, ":") {
return fmt.Errorf("PKGBUILD repository %q url %q must be absolute", name, url)
}
return nil
}
+147
View File
@@ -0,0 +1,147 @@
//go:build !integration
package settings
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Jguer/yay/v13/pkg/settings/lua"
)
// GIVEN an init.lua declaring pkgbuild_repos
// WHEN it is loaded onto a Configuration
// THEN the repos are applied, keyed by name and sorted deterministically
func TestPkgbuildReposFromLua(t *testing.T) {
t.Parallel()
dir := t.TempDir()
luaPath := filepath.Join(dir, "init.lua")
require.NoError(t, os.WriteFile(luaPath, []byte(`
yay.opt.pkgbuild_repos = {
["yay-pkgbuild"] = { url = "https://github.com/Jguer/yay-PKGBUILD", depth = 2 },
["local-repo"] = { url = "file:///srv/pkgbuilds" },
}
`), 0o600))
cfg := DefaultConfig("test")
require.NoError(t, lua.LoadInto(nil, luaPath, cfg))
require.Len(t, cfg.PkgbuildRepos, 2)
assert.Equal(t, "local-repo", cfg.PkgbuildRepos[0].Name)
assert.Equal(t, "file:///srv/pkgbuilds", cfg.PkgbuildRepos[0].URL)
assert.Equal(t, "yay-pkgbuild", cfg.PkgbuildRepos[1].Name)
assert.Equal(t, "https://github.com/Jguer/yay-PKGBUILD", cfg.PkgbuildRepos[1].URL)
assert.Equal(t, 2, cfg.PkgbuildRepos[1].Depth)
}
// GIVEN an init.lua that sets "name" inside an entry table
// WHEN it is loaded
// THEN it is rejected: the name comes from the table key, and honoring the
// inner key would let two entries collapse onto one name.
func TestPkgbuildReposRejectsNameOverride(t *testing.T) {
t.Parallel()
dir := t.TempDir()
luaPath := filepath.Join(dir, "init.lua")
require.NoError(t, os.WriteFile(luaPath, []byte(`
yay.opt.pkgbuild_repos = {
["a"] = { name = "dup", url = "https://example.invalid/a" },
["b"] = { name = "dup", url = "https://example.invalid/b" },
}
`), 0o600))
cfg := DefaultConfig("test")
assert.Error(t, lua.LoadInto(nil, luaPath, cfg))
}
// GIVEN repos where some omit depth
// WHEN NormalizePkgbuildRepos runs
// THEN missing depths fall back to the default of 3 and explicit depths are kept
func TestNormalizePkgbuildReposDefaultsDepth(t *testing.T) {
t.Parallel()
cfg := DefaultConfig("test")
cfg.PkgbuildRepos = []PkgbuildRepo{
{Name: "a", URL: "https://example.invalid/a"},
{Name: "b", URL: "https://example.invalid/b", Depth: 1},
}
require.NoError(t, cfg.NormalizePkgbuildRepos())
assert.Equal(t, DefaultPkgbuildRepoDepth, cfg.PkgbuildRepos[0].Depth)
assert.Equal(t, 1, cfg.PkgbuildRepos[1].Depth)
}
func TestNormalizePkgbuildReposRejectsUnsafeName(t *testing.T) {
t.Parallel()
for _, name := range []string{"../outside", "."} {
cfg := DefaultConfig("test")
cfg.PkgbuildRepos = []PkgbuildRepo{{Name: name, URL: "https://example.invalid/a"}}
assert.Error(t, cfg.NormalizePkgbuildRepos(), name)
}
}
// GIVEN URLs that Resolve would otherwise reinterpret as a local path
// WHEN they are normalized
// THEN they are rejected up front with a clear error instead of failing later
// as a confusing "no such file or directory".
func TestNormalizePkgbuildReposRejectsUnsafeURL(t *testing.T) {
t.Parallel()
for _, url := range []string{
"", // no url at all
"http://example.invalid/repo.git", // tamperable transport
"git://example.invalid/repo.git", // tamperable transport
"git+http://example.invalid/r.git", // tamperable transport behind git+
"ftp://example.invalid/repo", // unsupported scheme
"pkgbuilds", // relative to yay's cwd
"--upload-pack=/bin/false/repo.git", // would reach git as an option
} {
cfg := DefaultConfig("test")
cfg.PkgbuildRepos = []PkgbuildRepo{{Name: "r", URL: url}}
assert.Error(t, cfg.NormalizePkgbuildRepos(), "url %q must be rejected", url)
}
}
func TestNormalizePkgbuildReposAcceptsSupportedURLs(t *testing.T) {
t.Parallel()
for _, url := range []string{
"https://github.com/Jguer/yay-PKGBUILD",
"ssh://git@example.invalid/repo.git",
"git+file:///srv/pkgbuilds",
"file:///srv/pkgbuilds",
"/srv/pkgbuilds",
"git@example.invalid:user/repo.git",
} {
cfg := DefaultConfig("test")
cfg.PkgbuildRepos = []PkgbuildRepo{{Name: "r", URL: url}}
assert.NoError(t, cfg.NormalizePkgbuildRepos(), "url %q must be accepted", url)
}
}
// GIVEN two repos that resolve to the same name
// WHEN normalized
// THEN the duplicate is rejected, because both would share one cache checkout.
func TestNormalizePkgbuildReposRejectsDuplicateName(t *testing.T) {
t.Parallel()
cfg := DefaultConfig("test")
cfg.PkgbuildRepos = []PkgbuildRepo{
{Name: "dup", URL: "https://example.invalid/a"},
{Name: "dup", URL: "https://example.invalid/b"},
}
assert.Error(t, cfg.NormalizePkgbuildRepos())
}