add portage backend (#3676)

* add portage backend

* portage: add excludes
This commit is contained in:
EmiOnFlux authored and GitHub committed 2026-10-04 15:10:36 -04:00
1 parent 30c7171836
commit 375aa2f492
8 files changed
+495 -10

No files matched your search

+1 -5
View File
@@ -251,16 +251,12 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
return pkgs
}
ignored := make(map[string]bool, len(sysUpdateIgnore))
for _, name := range sysUpdateIgnore {
ignored[name] = true
}
out := pkgs[:0]
for _, p := range pkgs {
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
continue
}
if ignored[p.Name] {
if sysupdate.IsPackageIgnored(p.Name, sysUpdateIgnore) {
continue
}
out = append(out, p)
@@ -0,0 +1,261 @@
package sysupdate
import (
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
)
func init() {
RegisterSystemBackend(func() Backend { return &portageBackend{} })
}
// [ebuild U ] sys-apps/util-linux-2.40.4::gentoo [2.40.2::gentoo] USE="..."
var (
portageUpdateLine = regexp.MustCompile(`^\s*\[(ebuild|binary)\b[^\]]*\]\s+([^\s/]+)/([^\s\[\]]+?)::([^\s\[\]]+)(?:\s+\[\s*([^\s\]]+?)(?:::[^\s\]]+)?\])?`)
ansiRegexp = regexp.MustCompile(`\x1B\[[0-9;?]*[ -/]*[@-~]`)
)
func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
}
type portageBackend struct{}
func (portageBackend) ID() string { return "portage" }
func (portageBackend) DisplayName() string { return "Portage" }
func (portageBackend) Repo() RepoKind { return RepoSystem }
func (portageBackend) NeedsAuth() bool { return true }
func (portageBackend) RunsInTerminal() bool { return false }
func (portageBackend) IsAvailable(_ context.Context) bool { return commandExists("emerge") }
func (b portageBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := capturePortageUpdates(ctx)
if err != nil {
return nil, err
}
return parsePortageUpdates(out, b.ID()), nil
}
func (b portageBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
err := b.upgrade(ctx, opts, onLine)
if err != nil && isPortageAuthCanceled(err) {
return err
}
if err != nil && !opts.DryRun && ctx.Err() == nil {
notifyPortageFailure()
}
return err
}
// Notify the user that portage failed so they can check the logs and fix the issue manually
func notifyPortageFailure() {
if _, err := notify.Send(notify.Notification{
Summary: "Portage update failed",
Body: "Open Settings > Software Updates to read the error log.",
Timeout: 10000,
}); err != nil {
log.Warnf("[sysupdate] portage failure notification: %v", err)
}
}
func isPortageAuthCanceled(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "exit status 126")
}
func (b portageBackend) upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"emerge", "--pretend", "--update", "--newuse", "--deep", "@world"}, RunOptions{OnLine: onLine})
}
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil
}
if len(opts.Targets) > 0 && hasPortageTarget(opts.Targets) && !portageIsExcluded(opts) {
if err := runPortageFirst(ctx, opts, onLine); err != nil {
return err
}
}
ansiWrap := func(s string) {
if onLine != nil {
onLine(stripANSI(s))
}
}
return Run(ctx, portageUpgradeArgv(opts), RunOptions{OnLine: ansiWrap, AttachStdio: opts.AttachStdio})
}
// emerge upgrades all of @world, so ignores are the only way to hold a package back
func portageUpgradeArgv(opts UpgradeOptions) []string {
argv := []string{"emerge", "--update", "--newuse", "--deep", "--quiet"}
for _, atom := range shellSafeNames(opts.Ignored) {
argv = append(argv, "--exclude", atom)
}
argv = append(argv, "@world")
return privilegedArgv(opts, argv...)
}
// Portage can report names without slots, so a sloted ignore only matches slotless packages when it is slot 0.
func PackageMatchesIgnore(pkgName, ignored string) bool {
pkgBase, pkgSlot, _ := strings.Cut(pkgName, ":")
ignBase, ignSlot, _ := strings.Cut(ignored, ":")
if pkgSlot != "" && ignSlot != "" {
return strings.EqualFold(pkgBase, ignBase) && pkgSlot == ignSlot
}
if ignSlot != "" && pkgSlot == "" {
return strings.EqualFold(pkgBase, ignBase) && ignSlot == "0"
}
return strings.EqualFold(pkgBase, ignBase)
}
// IsPackageIgnored reports whether any ignore entry holds pkgName back.
func IsPackageIgnored(pkgName string, ignored []string) bool {
for _, ign := range ignored {
if PackageMatchesIgnore(pkgName, ign) {
return true
}
}
return false
}
// An ignored portage means the user holds it back, so the pre-update step must skip it too
func portageIsExcluded(opts UpgradeOptions) bool {
for _, ign := range opts.Ignored {
if IsPackageIgnored("sys-apps/portage", []string{ign}) {
return true
}
base, _, _ := strings.Cut(ign, ":")
if isPortagePackage(base) {
return true
}
}
return false
}
func isPortagePackage(name string) bool {
l := strings.ToLower(name)
return l == "sys-apps/portage" || l == "portage" || strings.HasSuffix(l, "/portage")
}
func hasPortageTarget(targets []Package) bool {
for _, p := range targets {
if p.Backend == "portage" && isPortagePackage(p.Name) {
return true
}
}
return false
}
// Before running any other system updates, portage should always be updated first
func runPortageFirst(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
argv := []string{"emerge", "--oneshot", "sys-apps/portage"}
return Run(ctx, privilegedArgv(opts, argv...), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
}
// Portage needs to sync the repos first before we can check for any available updates
// This requires root, there is no good way around this
// even if this causes the user to suddenly get pkexec prompts if they enable background updates
func portageSync(ctx context.Context) (string, error) {
argv := privilegedArgv(UpgradeOptions{}, "emerge", "--sync")
var out strings.Builder
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
if err != nil && isPortageAuthCanceled(err) {
return out.String(), errors.New("authentication request was dismissed or canceled")
}
if err != nil {
return out.String(), portageRunError(err, out.String())
}
return out.String(), nil
}
func portageRunError(err error, output string) error {
if detail := strings.TrimSpace(output); detail != "" {
return fmt.Errorf("%w: %s", err, detail)
}
return err
}
func capturePortageUpdates(ctx context.Context) (string, error) {
if _, err := portageSync(ctx); err != nil {
return "", err
}
cmd := exec.CommandContext(ctx, "emerge", "--pretend", "--verbose", "--update", "--newuse", "--deep", "--color=n", "@world")
cmd.Env = append(cmd.Environ(), "TERM=dumb", "LC_ALL=C")
var out, errOut strings.Builder
cmd.Stdout = &out
cmd.Stderr = &errOut
// Any non-zero exit means @world does not resolve, and a broken world prints no update list, so it must not read as up to date.
if err := cmd.Run(); err != nil {
return "", portageRunError(err, out.String()+errOut.String())
}
return out.String(), nil
}
func splitPkgVersion(pkgVer string) (pkg, ver string) {
for i := 0; i < len(pkgVer)-1; i++ {
if pkgVer[i] == '-' && pkgVer[i+1] >= '0' && pkgVer[i+1] <= '9' {
return pkgVer[:i], pkgVer[i+1:]
}
}
return pkgVer, pkgVer
}
func extractPkgAndVersion(pkgVer string) (pkg, ver string) {
pkg, ver = splitPkgVersion(pkgVer)
if pkg == pkgVer {
return "", ver
}
return pkg, ver
}
func parsePortageUpdates(text, backendID string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
line = stripANSI(line)
m := portageUpdateLine.FindStringSubmatch(line)
if m == nil {
continue
}
category := m[2]
pkgBase, newVer := splitPkgVersion(m[3])
_, oldVer := extractPkgAndVersion(m[5])
pkgs = append(pkgs, Package{
Name: category + "/" + pkgBase,
Repo: portageRepoLabel(m[1], m[4]),
Backend: backendID,
FromVersion: oldVer,
ToVersion: newVer,
})
}
return pkgs
}
// The GUI shows repo and build type in one slot, since Package has no field for it.
func portageRepoLabel(build, repo string) RepoKind {
if repo == "" {
repo = "gentoo"
}
return RepoKind(repo + " (" + build + ")")
}
@@ -0,0 +1,115 @@
package sysupdate
import (
"reflect"
"strings"
"testing"
)
func TestPortagePretendFailureIsNotAnEmptyUpdateList(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
writeUpdateExecutable(t, dir, "pkexec", `exec "$@"`)
writeUpdateExecutable(t, dir, "emerge", `case "$1" in
--sync) exit 0 ;;
--pretend) echo '!!! The following packages are blocked by another package:' >&2; exit 1 ;;
esac`)
out, err := capturePortageUpdates(t.Context())
if err == nil {
t.Fatalf("capturePortageUpdates() = %q, want error", out)
}
if out != "" {
t.Errorf("capturePortageUpdates() output = %q, want empty on failure", out)
}
if !strings.Contains(err.Error(), "blocked by another package") {
t.Errorf("error lost emerge stderr: %v", err)
}
}
func TestParsePortageUpdates(t *testing.T) {
input := `These are the packages that would be merged, in order:
Calculating dependencies ... done!
[ebuild U ] app-text/ansifilter-2.23::gentoo [2.22::gentoo] USE="gui -verify-sig" 0 KiB
[ebuild U ~] sys-apps/kmscon-10.0.4::gentoo [10.0.3::gentoo] USE="drm fbdev gles2 pango -debug" 0 KiB
[ebuild UD~] gui-wm/umbriel-0.0.0_pre20260924::guru [0.0.0_pre20261003::guru] USE="X%* screencast -jemalloc" 0 KiB
[binary gU ] mail-client/thunderbird-153.4.0-1:0/esr::gentoo [153.3.0:0/esr::gentoo] USE="X clang pulseaudio" 0 KiB
[binary rRg ] media-video/mpv-0.41.0-r2-21:0/2::gentoo USE="X alsa cdda cli" 0 KiB
[ebuild U ] www-client/librewolf-bin-157.0_p1::librewolf [156.0.1_p1::librewolf] USE="wayland (-selinux)" 0 KiB
[ebuild U ] dev-lang/rust-1.83.0:1.83::gentoo [1.82.0:1.82::gentoo] USE="lto rustfmt -clippy" 0 KiB
[ebuild U ] sys-libs/glibc-2.41-r3:2.2::gentoo [2.41-r2:2.2::gentoo] USE="multiarch -audit" 0 KiB
Total: 8 packages (8 upgrades), Size of downloads: 0 KiB
`
want := []Package{
{Name: "app-text/ansifilter", Repo: "gentoo (ebuild)", Backend: "portage", FromVersion: "2.22", ToVersion: "2.23"},
{Name: "sys-apps/kmscon", Repo: "gentoo (ebuild)", Backend: "portage", FromVersion: "10.0.3", ToVersion: "10.0.4"},
{Name: "gui-wm/umbriel", Repo: "guru (ebuild)", Backend: "portage", FromVersion: "0.0.0_pre20261003", ToVersion: "0.0.0_pre20260924"},
{Name: "mail-client/thunderbird", Repo: "gentoo (binary)", Backend: "portage", FromVersion: "153.3.0:0/esr", ToVersion: "153.4.0-1:0/esr"},
{Name: "media-video/mpv", Repo: "gentoo (binary)", Backend: "portage", ToVersion: "0.41.0-r2-21:0/2"},
{Name: "www-client/librewolf-bin", Repo: "librewolf (ebuild)", Backend: "portage", FromVersion: "156.0.1_p1", ToVersion: "157.0_p1"},
{Name: "dev-lang/rust", Repo: "gentoo (ebuild)", Backend: "portage", FromVersion: "1.82.0:1.82", ToVersion: "1.83.0:1.83"},
{Name: "sys-libs/glibc", Repo: "gentoo (ebuild)", Backend: "portage", FromVersion: "2.41-r2:2.2", ToVersion: "2.41-r3:2.2"},
}
got := parsePortageUpdates(input, "portage")
if !reflect.DeepEqual(got, want) {
t.Errorf("parsePortageUpdates() =\n%#v\nwant\n%#v", got, want)
}
}
func TestPortageRepoLabelDefaultsToGentoo(t *testing.T) {
if got := portageRepoLabel("ebuild", ""); got != RepoKind("gentoo (ebuild)") {
t.Errorf("portageRepoLabel(no repo) = %q, want %q", got, "gentoo (ebuild)")
}
}
func TestPortageIsExcluded(t *testing.T) {
tests := []struct {
name string
ignored []string
excluded bool
}{
{name: "no ignores", ignored: nil},
{name: "portage ignored", ignored: []string{"sys-apps/portage"}, excluded: true},
{name: "portage slot ignored", ignored: []string{"sys-apps/portage:0"}, excluded: true},
{name: "portage nonzero slot ignored", ignored: []string{"sys-apps/portage:2"}, excluded: true},
{name: "other package ignored", ignored: []string{"mail-client/thunderbird", "bad;name"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := portageIsExcluded(UpgradeOptions{Ignored: tt.ignored}); got != tt.excluded {
t.Fatalf("portageIsExcluded(%v) = %v, want %v", tt.ignored, got, tt.excluded)
}
})
}
}
func TestPackageMatchesIgnore(t *testing.T) {
tests := []struct {
pkg string
ignored string
want bool
}{
{pkg: "gui-wm/gamescope", ignored: "gui-wm/gamescope", want: true},
{pkg: "gui-wm/gamescope", ignored: "gui-wm/sway"},
{pkg: "app-misc/fastfetch:0", ignored: "app-misc/fastfetch", want: true},
{pkg: "app-misc/fastfetch:0", ignored: "app-misc/fastfetch:0", want: true},
{pkg: "app-misc/fastfetch:2", ignored: "app-misc/fastfetch:0"},
{pkg: "app-misc/fastfetch:2", ignored: "app-misc/fastfetch:2", want: true},
{pkg: "app-misc/fastfetch:5", ignored: "app-misc/fastfetch:5", want: true},
{pkg: "app-misc/fastfetch:2", ignored: "app-misc/fastfetch:0"},
{pkg: "app-misc/fastfetch:5", ignored: "app-misc/fastfetch:0"},
{pkg: "app-misc/fastfetch:2", ignored: "app-misc/fastfetch:5"},
{pkg: "app-misc/fastfetch", ignored: "app-misc/fastfetch:0", want: true},
{pkg: "app-misc/fastfetch", ignored: "app-misc/fastfetch:2"},
{pkg: "app-misc/fastfetch", ignored: "app-misc/fastfetch:5"},
}
for _, tt := range tests {
if got := PackageMatchesIgnore(tt.pkg, tt.ignored); got != tt.want {
t.Errorf("PackageMatchesIgnore(%q, %q) = %v, want %v", tt.pkg, tt.ignored, got, tt.want)
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ package sysupdate
import "regexp"
var safePkgName = regexp.MustCompile(`^[A-Za-z0-9@._+:-]+$`)
var safePkgName = regexp.MustCompile(`^[A-Za-z0-9@._+:/.-]+$`)
// shellSafeNames drops names unsafe to interpolate into the apt/zypper sh -c scripts.
func shellSafeNames(names []string) []string {
@@ -59,6 +59,16 @@ func TestUpgradeCommandBuilders(t *testing.T) {
got: dnfUpgradeArgv("dnf5", UpgradeOptions{Ignored: []string{"kernel", "mesa"}}),
want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y", "--exclude=kernel,mesa"},
},
{
name: "portage full world upgrade",
got: portageUpgradeArgv(pkexecOpts),
want: []string{"pkexec", "emerge", "--update", "--newuse", "--deep", "--quiet", "@world"},
},
{
name: "portage with ignored packages",
got: portageUpgradeArgv(UpgradeOptions{Ignored: []string{"sys-apps/portage", "bad;name", "mail-client/thunderbird"}}),
want: []string{"pkexec", "emerge", "--update", "--newuse", "--deep", "--quiet", "--exclude", "sys-apps/portage", "--exclude", "mail-client/thunderbird", "@world"},
},
{
name: "apt without ignored uses plain upgrade",
got: aptUpgradeArgv("apt-get", UpgradeOptions{}),
@@ -759,7 +759,7 @@ Item {
const name = newIgnoredPackageField.value.trim();
if (name === "")
return;
errorIsInvalidName = !/^[A-Za-z0-9@._+:-]+$/.test(name);
errorIsInvalidName = !SystemUpdateService.isValidIgnoredName(name);
if (errorIsInvalidName) {
ignoredPackageError.visible = true;
return;
@@ -797,7 +797,7 @@ Item {
SettingsRow {
id: ignoredPackageError
visible: false
title: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.")
title: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:/- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.")
titleColor: Theme.error
}
+42 -2
View File
@@ -93,6 +93,10 @@ Singleton {
return systemHoldsAllowed || pkg.repo !== "system";
}
function isValidIgnoredName(name) {
return /^[A-Za-z0-9@._+:\/-]+$/.test(name);
}
Connections {
target: DMSService
function onCapabilitiesReceived() {
@@ -228,14 +232,50 @@ Singleton {
_maybeNotify();
}
function _filterUpdates(pkgs) {
function _packageMatchesIgnore(pkgName, ignored) {
const split = (s) => {
if (!s)
return [s || "", ""];
const idx = s.indexOf(":");
if (idx === -1)
return [s, ""];
return [s.substring(0, idx), s.substring(idx + 1)];
};
const [pkgBase, pkgSlot] = split(pkgName);
const [ignBase, ignSlot] = split(ignored);
if (pkgSlot && ignSlot) {
if (pkgBase === ignBase && pkgSlot === ignSlot)
return true;
return false;
}
if (ignSlot && !pkgSlot) {
if (pkgBase === ignBase && ignSlot === "0")
return true;
return false;
}
if (pkgBase === ignBase)
return true;
return false;
}
function _isIgnored(pkg) {
if (!pkg || !pkg.name)
return false;
const ignored = SettingsData.updaterIgnoredPackages || [];
for (let i = 0; i < ignored.length; i++) {
if (_packageMatchesIgnore(pkg.name, ignored[i]))
return true;
}
return false;
}
function _filterUpdates(pkgs) {
return (pkgs || []).filter(p => {
if (!SettingsData.updaterAllowAUR && p.repo === "aur")
return false;
if (!canIgnorePackage(p))
return true;
return ignored.indexOf(p.name) === -1;
return !_isIgnored(p);
});
}
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
const source = readFileSync(new URL("../Services/SystemUpdateService.qml", import.meta.url), "utf8");
const extract = (name) => {
const found = source.match(new RegExp(`^ function ${name}\\([^)]*\\) \\{\\n[\\s\\S]*?^ \\}`, "m"));
if (!found)
throw new Error(`${name} not found in SystemUpdateService.qml`);
return found[0];
};
const scope = vm.createContext({
SettingsData: { updaterIgnoredPackages: [], updaterAllowAUR: true },
pkgManager: "portage",
systemHoldsAllowed: true,
});
for (const fn of ["isValidIgnoredName", "_packageMatchesIgnore", "_isIgnored", "canIgnorePackage", "_filterUpdates"])
vm.runInContext(extract(fn), scope);
test("portage atoms and other package names can be ignored", () => {
for (const name of ["sys-apps/portage", "mail-client/thunderbird:0", "docker", "org.mozilla.firefox", "bash.x86_64", "gtk+"])
assert.equal(scope.isValidIgnoredName(name), true, name);
});
test("names with spaces or shell punctuation are rejected", () => {
for (const name of ["", "sys apps", "a;b", "$(reboot)", "sys-apps/portage;reboot", "sys-apps/*"])
assert.equal(scope.isValidIgnoredName(name), false, name);
});
test("ignores match on base name when the ignore carries no slot", () => {
const cases = [
["gui-wm/gamescope", "gui-wm/gamescope", true],
["app-misc/fastfetch:0", "app-misc/fastfetch", true],
["mail-client/thunderbird:0", "mail-client/thunderbird", true],
["gui-wm/gamescope", "gui-wm/sway", false],
];
for (const [pkg, ignored, want] of cases)
assert.equal(scope._packageMatchesIgnore(pkg, ignored), want, `${pkg} vs ${ignored}`);
});
test("a slot in the ignore only matches that slot, or slot 0 when the package has none", () => {
const cases = [
["app-misc/fastfetch:0", "app-misc/fastfetch:0", true],
["app-misc/fastfetch:2", "app-misc/fastfetch:2", true],
["app-misc/fastfetch:2", "app-misc/fastfetch:0", false],
["app-misc/fastfetch", "app-misc/fastfetch:0", true],
["app-misc/fastfetch", "app-misc/fastfetch:2", false],
];
for (const [pkg, ignored, want] of cases)
assert.equal(scope._packageMatchesIgnore(pkg, ignored), want, `${pkg} vs ${ignored}`);
});
test("portage ignores drop the update from the list", () => {
scope.SettingsData.updaterIgnoredPackages = ["gui-wm/gamescope"];
const pkgs = [
{ name: "gui-wm/gamescope", repo: "gentoo (ebuild)", backend: "portage" },
{ name: "mail-client/thunderbird", repo: "gentoo (ebuild)", backend: "portage" },
];
const kept = scope._filterUpdates(pkgs);
assert.deepEqual(kept.map(p => p.name), ["mail-client/thunderbird"]);
});