From 91b3a7a1dfe8d4be8aa77b56adac752953caaa97 Mon Sep 17 00:00:00 2001 From: Jo Garnier Date: Fri, 7 Aug 2026 16:06:11 +0900 Subject: [PATCH] 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. --- main.go | 7 ++ meta/yay.d.lua | 7 ++ pkg/settings/config.go | 4 + pkg/settings/pkgbuild_repo.go | 106 +++++++++++++++++++++ pkg/settings/pkgbuild_repo_test.go | 147 +++++++++++++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 pkg/settings/pkgbuild_repo.go create mode 100644 pkg/settings/pkgbuild_repo_test.go diff --git a/main.go b/main.go index d888f59e..f4d98df1 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/meta/yay.d.lua b/meta/yay.d.lua index 7766410b..24bf8c7a 100644 --- a/meta/yay.d.lua +++ b/meta/yay.d.lua @@ -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 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 diff --git a/pkg/settings/config.go b/pkg/settings/config.go index 54946c6e..68813485 100644 --- a/pkg/settings/config.go +++ b/pkg/settings/config.go @@ -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:"-"` diff --git a/pkg/settings/pkgbuild_repo.go b/pkg/settings/pkgbuild_repo.go new file mode 100644 index 00000000..b0021fe3 --- /dev/null +++ b/pkg/settings/pkgbuild_repo.go @@ -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 +} diff --git a/pkg/settings/pkgbuild_repo_test.go b/pkg/settings/pkgbuild_repo_test.go new file mode 100644 index 00000000..7c679bf2 --- /dev/null +++ b/pkg/settings/pkgbuild_repo_test.go @@ -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()) +}