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:
1 parent
0043665f62
commit
91b3a7a1df
5 files changed
+271
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in new issue
Block a user