diff --git a/doc/init.lua b/doc/init.lua index f27e4eab..efc23359 100644 --- a/doc/init.lua +++ b/doc/init.lua @@ -73,7 +73,7 @@ yay.opt.double_confirm = true -- Ask for confirmation before and after builds du -- end -- end -- --- return { exclude = exclude, skip_menu = true } +-- return { exclude = exclude, skip_menu = false } -- end, -- }) -- @@ -95,3 +95,15 @@ yay.opt.double_confirm = true -- Ask for confirmation before and after builds du -- -- f:close() -- end, -- }) +-- +-- Run Lua after yay downloads/verifies package sources and before builds or +-- installs. AURPostDownload receives the same payload shape as AURPreInstall. +-- +-- yay.create_autocmd("AURPostDownload", { +-- desc = "block forbidden source URLs after download", +-- callback = function(event) +-- if event.data.pkgbuild:match("forbidden.example") then +-- yay.abort(event.match .. ": forbidden source URL") +-- end +-- end, +-- }) diff --git a/doc/lua.md b/doc/lua.md index fd0b99dd..9826d5e7 100644 --- a/doc/lua.md +++ b/doc/lua.md @@ -83,7 +83,7 @@ yay.create_autocmd("UpgradeSelect", { end end - return { exclude = exclude, skip_menu = true } + return { exclude = exclude, skip_menu = false } end, }) ``` @@ -244,3 +244,49 @@ yay.create_autocmd("AURPreInstall", { end, }) ``` + +## AUR post-download hooks + +`AURPostDownload` runs once per AUR package base, in sorted package-base order, +after yay runs `makepkg --verifysource` for package sources and before +compatibility checks, PGP key import prompts, builds, or package installs. + +Use `yay.abort("message")` to stop the operation without a Lua traceback. +`AURPostDownload` receives the same payload shape as `AURPreInstall`; only the +`event` value differs. + +### AURPostDownload event + +The callback receives this table: + +```lua +{ + event = "AURPostDownload", + match = "pkgbase", + data = { + base = "pkgbase", + dir = "/path/to/build/pkgbase", + pkgbuild_path = "/path/to/build/pkgbase/PKGBUILD", + srcinfo_path = "/path/to/build/pkgbase/.SRCINFO", + pkgbuild = "...PKGBUILD contents...", + version = "1:1.2.3-4", + last_modified = 1700000000, + installed = true, + packages = { ... }, + srcinfo = { ... }, + }, +} +``` + +### Example + +```lua +yay.create_autocmd("AURPostDownload", { + desc = "block forbidden source URLs after download", + callback = function(event) + if event.data.pkgbuild:match("forbidden.example") then + yay.abort(event.match .. ": forbidden source URL") + end + end, +}) +``` diff --git a/pkg/settings/lua/abort.go b/pkg/settings/lua/abort.go index 5b16c7d9..909304f7 100644 --- a/pkg/settings/lua/abort.go +++ b/pkg/settings/lua/abort.go @@ -37,3 +37,13 @@ func luaAbortError(err error) (abortError, bool) { return abortErr, ok } + +// wrapLuaErr strips the gopher-lua API wrapper from abort errors so callers +// see the clean abort message instead of a Lua traceback. +func wrapLuaErr(err error) error { + if abortErr, ok := luaAbortError(err); ok { + return abortErr + } + + return err +} diff --git a/pkg/settings/lua/autocmd.go b/pkg/settings/lua/autocmd.go index 179544c6..f19f3350 100644 --- a/pkg/settings/lua/autocmd.go +++ b/pkg/settings/lua/autocmd.go @@ -8,8 +8,9 @@ import ( ) const ( - EventAURPreInstall = "AURPreInstall" - EventUpgradeSelect = "UpgradeSelect" + EventAURPreInstall = "AURPreInstall" + EventAURPostDownload = "AURPostDownload" + EventUpgradeSelect = "UpgradeSelect" ) type Autocmd struct { @@ -82,7 +83,7 @@ type UpgradeSelectResult struct { func (e *Engine) createAutocmd(state *glua.LState) int { event := state.CheckString(1) - if event != EventAURPreInstall && event != EventUpgradeSelect { + if event != EventAURPreInstall && event != EventAURPostDownload && event != EventUpgradeSelect { state.ArgError(1, fmt.Sprintf("unsupported event %q", event)) return 0 } @@ -128,13 +129,26 @@ func (e *Engine) RunAURPreInstall(event *AURPreInstallEvent) error { Fn: autocmd.callback, NRet: 0, Protect: true, - }, e.aurPreInstallTable(event)); err != nil { - wrapped := err - if abortErr, ok := luaAbortError(err); ok { - wrapped = abortErr - } + }, e.aurEventTable(EventAURPreInstall, event)); err != nil { + return fmt.Errorf("%s %s: %w", EventAURPreInstall, event.Base, wrapLuaErr(err)) + } + } - return fmt.Errorf("%s %s: %w", EventAURPreInstall, event.Base, wrapped) + return nil +} + +func (e *Engine) RunAURPostDownload(event *AURPreInstallEvent) error { + if !e.HasAutocmd(EventAURPostDownload) { + return nil + } + + for _, autocmd := range e.autocmds[EventAURPostDownload] { + if err := e.L.CallByParam(glua.P{ + Fn: autocmd.callback, + NRet: 0, + Protect: true, + }, e.aurEventTable(EventAURPostDownload, event)); err != nil { + return fmt.Errorf("%s %s: %w", EventAURPostDownload, event.Base, wrapLuaErr(err)) } } @@ -159,12 +173,7 @@ func (e *Engine) RunUpgradeSelect(event *UpgradeSelectEvent) (UpgradeSelectResul NRet: 1, Protect: true, }, e.upgradeSelectTable(event)); err != nil { - wrapped := err - if abortErr, ok := luaAbortError(err); ok { - wrapped = abortErr - } - - return result, fmt.Errorf("%s: %w", EventUpgradeSelect, wrapped) + return result, fmt.Errorf("%s: %w", EventUpgradeSelect, wrapLuaErr(err)) } value := e.L.Get(-1) @@ -190,12 +199,12 @@ func (e *Engine) RunUpgradeSelect(event *UpgradeSelectEvent) (UpgradeSelectResul return result, nil } -func (e *Engine) aurPreInstallTable(event *AURPreInstallEvent) *glua.LTable { +func (e *Engine) aurEventTable(eventName string, event *AURPreInstallEvent) *glua.LTable { state := e.L eventTable := state.NewTable() data := state.NewTable() - eventTable.RawSetString("event", glua.LString(EventAURPreInstall)) + eventTable.RawSetString("event", glua.LString(eventName)) eventTable.RawSetString("match", glua.LString(event.Base)) eventTable.RawSetString("data", data) @@ -312,18 +321,19 @@ func (e *Engine) parseUpgradeSelectResult(value glua.LValue, validExcludes mapse return } - name, ok := val.(glua.LString) + lname, ok := val.(glua.LString) if !ok { parseErr = fmt.Errorf("exclude entries must be strings") return } - if !validExcludes.Contains(string(name)) { - parseErr = fmt.Errorf("unknown upgrade exclusion %q", string(name)) + name := string(lname) + if !validExcludes.Contains(name) { + parseErr = fmt.Errorf("unknown upgrade exclusion %q", name) return } - result.Exclude = append(result.Exclude, string(name)) + result.Exclude = append(result.Exclude, name) }) if parseErr != nil { return result, parseErr diff --git a/pkg/settings/lua/autocmd_test.go b/pkg/settings/lua/autocmd_test.go index e5205088..5258af69 100644 --- a/pkg/settings/lua/autocmd_test.go +++ b/pkg/settings/lua/autocmd_test.go @@ -65,6 +65,23 @@ func TestCreateAutocmdRegistersUpgradeSelect(t *testing.T) { require.True(t, e.HasAutocmd(EventUpgradeSelect)) } +func TestCreateAutocmdRegistersAURPostDownload(t *testing.T) { + e := New() + defer e.Close() + + require.NoError(t, e.L.DoString(` + yay.create_autocmd("AURPostDownload", { + desc = "inspect downloaded sources", + callback = function() end, + }) + `)) + + autocmds := e.autocmds[EventAURPostDownload] + require.Len(t, autocmds, 1) + require.Equal(t, "inspect downloaded sources", autocmds[0].Desc) + require.True(t, e.HasAutocmd(EventAURPostDownload)) +} + func TestCreateAutocmdRejectsInvalidEvent(t *testing.T) { e := New() defer e.Close() @@ -125,6 +142,76 @@ func TestRunAURPreInstallReturnsAbortWithoutTraceback(t *testing.T) { require.EqualError(t, err, "AURPreInstall demo-base: blocked by policy") } +func TestRunAURPostDownloadEventTableShape(t *testing.T) { + e := New() + defer e.Close() + + seen := []string{} + e.L.SetGlobal("record", e.L.NewFunction(func(L *glua.LState) int { + seen = append(seen, L.CheckString(1)) + return 0 + })) + + require.NoError(t, e.L.DoString(` + yay.create_autocmd("AURPostDownload", { + callback = function(event) + if event.event ~= "AURPostDownload" then error("bad event") end + if event.match ~= "demo-base" then error("bad match") end + if event.data.base ~= "demo-base" then error("bad base") end + if event.data.dir ~= "/build/demo-base" then error("bad dir") end + if event.data.pkgbuild_path ~= "/build/demo-base/PKGBUILD" then error("bad pkgbuild path") end + if event.data.srcinfo_path ~= "/build/demo-base/.SRCINFO" then error("bad srcinfo path") end + if event.data.pkgbuild ~= "pkgbase=demo-base" then error("bad pkgbuild") end + if event.data.version ~= "1.0-1" then error("bad version") end + if event.data.last_modified ~= 123 then error("bad last modified") end + if event.data.installed ~= true then error("bad installed") end + if event.data.packages[1].name ~= "demo" then error("bad package") end + if event.data.srcinfo.pkgbase ~= "demo-base" then error("bad srcinfo") end + if event.data.install_paths ~= nil then error("unexpected install paths") end + if event.data.source_paths ~= nil then error("unexpected source paths") end + if event.data.sources ~= nil then error("unexpected sources") end + + record(event.match .. ":" .. event.data.pkgbuild_path) + end, + }) + `)) + + err := e.RunAURPostDownload(&AURPreInstallEvent{ + Base: "demo-base", + Dir: "/build/demo-base", + PKGBUILDPath: "/build/demo-base/PKGBUILD", + SRCINFOPath: "/build/demo-base/.SRCINFO", + PKGBUILD: "pkgbase=demo-base", + Version: "1.0-1", + LastModified: 123, + Installed: true, + Packages: []AURPreInstallPackage{{ + Name: "demo", + }}, + SRCINFO: AURPreInstallSRCINFO{ + Pkgbase: "demo-base", + }, + }) + require.NoError(t, err) + require.Equal(t, []string{"demo-base:/build/demo-base/PKGBUILD"}, seen) +} + +func TestRunAURPostDownloadReturnsAbortWithoutTraceback(t *testing.T) { + e := New() + defer e.Close() + + require.NoError(t, e.L.DoString(` + yay.create_autocmd("AURPostDownload", { + callback = function() + yay.abort("blocked by policy") + end, + }) + `)) + + err := e.RunAURPostDownload(&AURPreInstallEvent{Base: "demo-base"}) + require.EqualError(t, err, "AURPostDownload demo-base: blocked by policy") +} + func TestRunUpgradeSelectEventTableShapeAndReturn(t *testing.T) { e := New() defer e.Close() diff --git a/pkg/sync/workdir/aur_post_download.go b/pkg/sync/workdir/aur_post_download.go new file mode 100644 index 00000000..559b6800 --- /dev/null +++ b/pkg/sync/workdir/aur_post_download.go @@ -0,0 +1,50 @@ +package workdir + +import ( + "github.com/Jguer/yay/v12/pkg/dep" + "github.com/Jguer/yay/v12/pkg/runtime" + settingslua "github.com/Jguer/yay/v12/pkg/settings/lua" + + mapset "github.com/deckarep/golang-set/v2" +) + +func runAURPostDownloadLuaHooks(run *runtime.Runtime, pkgbuildDirsByBase map[string]string, + installed mapset.Set[string], targets []map[string]*dep.InstallInfo, +) error { + if run == nil || run.Lua == nil || !run.Lua.HasAutocmd(settingslua.EventAURPostDownload) { + return nil + } + + events, err := aurPostDownloadEvents(pkgbuildDirsByBase, installed, targets) + if err != nil { + return err + } + + for i := range events { + if err := run.Lua.RunAURPostDownload(&events[i]); err != nil { + return err + } + } + + return nil +} + +func aurPostDownloadEvents(pkgbuildDirsByBase map[string]string, installed mapset.Set[string], + targets []map[string]*dep.InstallInfo, +) ([]settingslua.AURPreInstallEvent, error) { + packagesByBase := aurTargetPackagesByBase(targets) + bases := sortedAURBases(pkgbuildDirsByBase) + + events := make([]settingslua.AURPreInstallEvent, 0, len(bases)) + for _, base := range bases { + event, err := aurPackageEvent(settingslua.EventAURPostDownload, base, pkgbuildDirsByBase[base], + packagesByBase[base], installed, targets) + if err != nil { + return nil, err + } + + events = append(events, event) + } + + return events, nil +} diff --git a/pkg/sync/workdir/aur_post_download_test.go b/pkg/sync/workdir/aur_post_download_test.go new file mode 100644 index 00000000..7bb2d3b3 --- /dev/null +++ b/pkg/sync/workdir/aur_post_download_test.go @@ -0,0 +1,103 @@ +//go:build !integration +// +build !integration + +package workdir + +import ( + "os" + "path/filepath" + "testing" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/stretchr/testify/require" + glua "github.com/yuin/gopher-lua" + + "github.com/Jguer/yay/v12/pkg/dep" + "github.com/Jguer/yay/v12/pkg/runtime" + settingslua "github.com/Jguer/yay/v12/pkg/settings/lua" +) + +func TestAURPostDownloadEventsUseAURPreInstallPayload(t *testing.T) { + base := "demo-base" + dir := writeAURPostDownloadPackage(t, base) + + events, err := aurPostDownloadEvents(map[string]string{base: dir}, + mapset.NewThreadUnsafeSet[string](), + []map[string]*dep.InstallInfo{ + { + "demo": {Source: dep.AUR, AURBase: &base, Version: "1.0-1"}, + }, + }) + require.NoError(t, err) + require.Len(t, events, 1) + + event := events[0] + require.Equal(t, base, event.Base) + require.Equal(t, dir, event.Dir) + require.Equal(t, filepath.Join(dir, "PKGBUILD"), event.PKGBUILDPath) + require.Equal(t, filepath.Join(dir, ".SRCINFO"), event.SRCINFOPath) + require.Contains(t, event.PKGBUILD, "pkgbase=demo-base") + require.Equal(t, "1.0-1", event.Version) + require.Equal(t, base, event.SRCINFO.Pkgbase) + require.Equal(t, "1.0", event.SRCINFO.Pkgver) + require.Equal(t, "1", event.SRCINFO.Pkgrel) + require.Equal(t, "1.0-1", event.SRCINFO.Version) + require.Equal(t, []string{"any"}, event.SRCINFO.Arch) + require.Equal(t, []settingslua.AURPreInstallPackage{{Name: "demo", Version: "1.0-1", Reason: "explicit"}}, event.Packages) +} + +func TestRunAURPostDownloadLuaHooksRunsBasesInSortedOrder(t *testing.T) { + firstDir := writeAURPostDownloadPackage(t, "a-base") + secondDir := writeAURPostDownloadPackage(t, "z-base") + + engine := settingslua.New() + defer engine.Close() + + order := []string{} + engine.L.SetGlobal("record", engine.L.NewFunction(func(L *glua.LState) int { + order = append(order, L.CheckString(1)) + return 0 + })) + require.NoError(t, engine.L.DoString(` + yay.create_autocmd("AURPostDownload", { + callback = function(event) + record(event.match .. ":" .. event.data.pkgbuild_path) + end, + }) + `)) + + err := runAURPostDownloadLuaHooks(&runtime.Runtime{Lua: engine}, + map[string]string{"z-base": secondDir, "a-base": firstDir}, + mapset.NewThreadUnsafeSet[string](), + []map[string]*dep.InstallInfo{ + { + "a": {Source: dep.AUR, AURBase: ptrString("a-base")}, + "z": {Source: dep.AUR, AURBase: ptrString("z-base")}, + }, + }) + require.NoError(t, err) + require.Equal(t, []string{ + "a-base:" + filepath.Join(firstDir, "PKGBUILD"), + "z-base:" + filepath.Join(secondDir, "PKGBUILD"), + }, order) +} + +func writeAURPostDownloadPackage(t *testing.T, base string) string { + t.Helper() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "PKGBUILD"), []byte(`pkgbase=`+base+` +pkgname=(demo) +pkgver=1.0 +pkgrel=1 +`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".SRCINFO"), []byte(`pkgbase = `+base+` + pkgver = 1.0 + pkgrel = 1 + arch = any + +pkgname = demo +`), 0o600)) + + return dir +} diff --git a/pkg/sync/workdir/aur_preinstall.go b/pkg/sync/workdir/aur_preinstall.go index 4674209e..0cceca13 100644 --- a/pkg/sync/workdir/aur_preinstall.go +++ b/pkg/sync/workdir/aur_preinstall.go @@ -90,19 +90,25 @@ func sortedAURBases(pkgbuildDirsByBase map[string]string) []string { func aurPreInstallEvent(base, path string, packages []settingslua.AURPreInstallPackage, installed mapset.Set[string], targets []map[string]*dep.InstallInfo, +) (settingslua.AURPreInstallEvent, error) { + return aurPackageEvent(settingslua.EventAURPreInstall, base, path, packages, installed, targets) +} + +func aurPackageEvent(eventName, base, path string, packages []settingslua.AURPreInstallPackage, + installed mapset.Set[string], targets []map[string]*dep.InstallInfo, ) (settingslua.AURPreInstallEvent, error) { dir, pkgbuildPath, srcinfoPath := aurPreInstallPaths(path) pkgbuildBytes, err := os.ReadFile(pkgbuildPath) if err != nil { return settingslua.AURPreInstallEvent{}, - fmt.Errorf("%s %s: read PKGBUILD: %w", settingslua.EventAURPreInstall, base, err) + fmt.Errorf("%s %s: read PKGBUILD: %w", eventName, base, err) } srcinfo, err := gosrc.ParseFile(srcinfoPath) if err != nil { return settingslua.AURPreInstallEvent{}, - fmt.Errorf("%s %s: parse .SRCINFO: %w", settingslua.EventAURPreInstall, base, err) + fmt.Errorf("%s %s: parse .SRCINFO: %w", eventName, base, err) } if len(packages) == 0 { diff --git a/pkg/sync/workdir/preparer.go b/pkg/sync/workdir/preparer.go index 58395582..c1c8c5ae 100644 --- a/pkg/sync/workdir/preparer.go +++ b/pkg/sync/workdir/preparer.go @@ -235,6 +235,10 @@ func (preper *Preparer) PrepareWorkspace(ctx context.Context, preper.log.Errorln(errP) } + if err := runAURPostDownloadLuaHooks(run, pkgBuildDirsByBase, remoteNamesCache, targets); err != nil { + return nil, err + } + return pkgBuildDirsByBase, nil }