diff --git a/agent/package_updates.go b/agent/package_updates.go index 9697f6392..a7e29b4eb 100644 --- a/agent/package_updates.go +++ b/agent/package_updates.go @@ -24,6 +24,9 @@ const ( // pacmanSyncInterval limits how often checkupdates downloads fresh sync // databases. Checks in between reuse the last synced copy. pacmanSyncInterval = 12 * time.Hour + // xbpsSyncInterval limits how often xbps-install fetches remote repository + // data. Checks in between reuse the last fetched update list. + xbpsSyncInterval = 12 * time.Hour ) // packageUpdatesResult is the outcome of one package manager check. @@ -154,6 +157,8 @@ func detectPackageManager(dataDir string) (string, packageUpdatesCheck) { return "pacman", newPacmanCheck(dataDir) case commandExists("apk"): return "apk", checkApk + case commandExists("xbps-install"): + return "xbps", newXbpsCheck() } return "", nil } @@ -509,3 +514,88 @@ func splitApkNameVersion(s string) (name, version string) { } return s[:ver], s[ver+1:] } + +// newXbpsCheck fetches remote repository data into memory (-M) every +// xbpsSyncInterval. The system's on-disk copy is only refreshed by xbps-install -S, +// so it is often stale, and xbps cannot keep the fetched data. Checks in between +// reuse the last fetched update list and drop packages whose installed version +// changed since, so local upgrades show up right away. xbps has no security +// metadata, so the security count is omitted. +func newXbpsCheck() packageUpdatesCheck { + // checks never overlap (packageUpdatesManager.running), so no lock is needed + var lastSync time.Time + var synced []system.PackageUpdate + return func(ctx context.Context) (packageUpdatesResult, error) { + sync := lastSync.IsZero() || time.Since(lastSync) >= xbpsSyncInterval + var updates []system.PackageUpdate + if sync { + out, err := runPackageCommand(ctx, nil, "xbps-install", "-Mun") + if err != nil { + return packageUpdatesResult{}, err + } + updates = parseXbpsSimulate(out) + } + out, err := runPackageCommand(ctx, nil, "xbps-query", "-l") + if err != nil { + return packageUpdatesResult{}, err + } + installed := parseXbpsInstalled(out) + if sync { + for i := range updates { + updates[i].Current = installed[updates[i].Name] + } + synced = updates + lastSync = time.Now() + } + var packages []system.PackageUpdate + for _, pkg := range synced { + // skip packages upgraded or removed since the last sync + if ver, ok := installed[pkg.Name]; ok && ver == pkg.Current { + packages = append(packages, pkg) + } + } + return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil + } +} + +// parseXbpsSimulate parses upgrades in `xbps-install -Mun` output. Upgrade lines look like +// "libgbm-26.2.4_1 update x86_64 https://void.sakamoto.pl/current 18488 6762" +func parseXbpsSimulate(out string) (packages []system.PackageUpdate) { + scanner := bufio.NewScanner(strings.NewReader(out)) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) < 2 || fields[1] != "update" { + continue + } + name, ver := parseXbpsNameVersion(fields[0]) + packages = append(packages, system.PackageUpdate{Name: name, Available: ver}) + } + return packages +} + +// parseXbpsInstalled maps package names to installed versions from `xbps-query -l` +// output. Lines look like "ii xbps-0.60.7_1 XBPS package system utilities". +func parseXbpsInstalled(out string) map[string]string { + installed := make(map[string]string) + scanner := bufio.NewScanner(strings.NewReader(out)) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + name, ver := parseXbpsNameVersion(fields[1]) + installed[name] = ver + } + return installed +} + +// parseXbpsNameVersion splits name-version to tuple (name, version) +// Names may contain dashes, but versions do not. +func parseXbpsNameVersion(s string) (name, ver string) { + i := strings.LastIndexByte(s, '-') + if i < 0 { + return s, "" + } + return s[:i], s[i+1:] +} diff --git a/agent/package_updates_test.go b/agent/package_updates_test.go index 68d67134b..7b0131f7e 100644 --- a/agent/package_updates_test.go +++ b/agent/package_updates_test.go @@ -191,6 +191,66 @@ func TestParseRpmInstalled(t *testing.T) { assert.Equal(t, "5.14.0-503.el9", installed["kernel.x86_64"]) } +func TestParseXbpsSimulate(t *testing.T) { + packages := parseXbpsSimulate(readPackageUpdatesTestData(t, "xbps_void_check_update.txt")) + assert.Len(t, packages, 3) + assert.Equal(t, system.PackageUpdate{Name: "libmariadbclient", Available: "12.3.2_2"}, findPackage(t, packages, "libmariadbclient")) + + packages = parseXbpsSimulate(readPackageUpdatesTestData(t, "xbps_void_install_update.txt")) + assert.Len(t, packages, 7) + assert.Equal(t, system.PackageUpdate{Name: "ca-certificates", Available: "20250419+3.127_1"}, findPackage(t, packages, "ca-certificates")) + assert.Equal(t, system.PackageUpdate{Name: "libcrypto3", Available: "3.6.5_1"}, findPackage(t, packages, "libcrypto3")) + // new dependencies are not updates + assert.Empty(t, parseXbpsSimulate("libfoo-1.0_1 install x86_64 https://repo-default.voidlinux.org/current 100 50\n")) + assert.Empty(t, parseXbpsSimulate("\n")) +} + +func TestParseXbpsInstalled(t *testing.T) { + installed := parseXbpsInstalled(readPackageUpdatesTestData(t, "xbps_void_query_installed.txt")) + assert.Len(t, installed, 29) + assert.Equal(t, "0.60.7_1", installed["xbps"]) + assert.Equal(t, "0.131_1", installed["xbps-triggers"]) + assert.Equal(t, "20250419+3.127_1", installed["ca-certificates"]) +} + +func TestXbpsCheckSync(t *testing.T) { + binDir := t.TempDir() + logFile := filepath.Join(binDir, "calls.log") + installedFile := filepath.Join(binDir, "installed.txt") + writeInstalled := func(lines string) { + require.NoError(t, os.WriteFile(installedFile, []byte(lines), 0o644)) + } + writeInstalled("ii bash-5.2.021_1 GNU Bourne Again Shell\nii libgbm-26.2.3_1 Mesa gbm library\n") + fakeCommands(t, map[string]string{ + "xbps-install": `echo "xbps-install $*" >> ` + logFile + ` +echo "bash-5.3.9_1 update x86_64 https://repo-default.voidlinux.org/current 100 50" +echo "libgbm-26.2.4_1 update x86_64 https://repo-default.voidlinux.org/current 100 50"`, + "xbps-query": `cat ` + installedFile, + }) + check := newXbpsCheck() + + // first check fetches remote data + result, err := check(context.Background()) + require.NoError(t, err) + assert.Equal(t, []uint16{2}, result.counts) + assert.False(t, result.securityKnown) + assert.Equal(t, []system.PackageUpdate{ + {Name: "bash", Current: "5.2.021_1", Available: "5.3.9_1"}, + {Name: "libgbm", Current: "26.2.3_1", Available: "26.2.4_1"}, + }, result.packages) + + // later checks reuse the fetched list and drop locally upgraded packages + writeInstalled("ii bash-5.3.9_1 GNU Bourne Again Shell\nii libgbm-26.2.3_1 Mesa gbm library\n") + result, err = check(context.Background()) + require.NoError(t, err) + assert.Equal(t, []uint16{1}, result.counts) + assert.Equal(t, []system.PackageUpdate{{Name: "libgbm", Current: "26.2.3_1", Available: "26.2.4_1"}}, result.packages) + + data, err := os.ReadFile(logFile) + require.NoError(t, err) + assert.Equal(t, "xbps-install -Mun\n", string(data)) +} + func TestCheckDnf(t *testing.T) { tests := []struct { name, updates, security, installed string @@ -255,6 +315,7 @@ func TestDetectPackageManager(t *testing.T) { {"zypper-aptitude", map[string]string{"apt-get": "", "zypper": ""}, "zypper"}, {"zypper-aptitude with dpkg", map[string]string{"apt-get": "", "dpkg-query": "exit 1", "zypper": ""}, "zypper"}, {"dnf", map[string]string{"dnf": "", "rpm": ""}, "dnf"}, + {"xbps", map[string]string{"xbps-install": ""}, "xbps"}, {"none", nil, ""}, } for _, tt := range tests { diff --git a/agent/test-data/package_updates/xbps_void_check_update.txt b/agent/test-data/package_updates/xbps_void_check_update.txt new file mode 100644 index 000000000..27e9405fb --- /dev/null +++ b/agent/test-data/package_updates/xbps_void_check_update.txt @@ -0,0 +1,3 @@ +libgbm-26.2.4_1 update x86_64 https://void.sakamoto.pl/current 18488 6762 +libmariadbclient-12.3.2_2 update x86_64 https://void.sakamoto.pl/current 27749752 7203074 +libpipewire-1.6.9_1 update x86_64 https://void.sakamoto.pl/current 4895536 1155261 diff --git a/agent/test-data/package_updates/xbps_void_install_update.txt b/agent/test-data/package_updates/xbps_void_install_update.txt new file mode 100644 index 000000000..d07615fa2 --- /dev/null +++ b/agent/test-data/package_updates/xbps_void_install_update.txt @@ -0,0 +1,7 @@ +acl-2.4.0_1 update x86_64 https://repo-default.voidlinux.org/current 43080 20653 +attr-2.6.0_1 update x86_64 https://repo-default.voidlinux.org/current 27348 9495 +ca-certificates-20250419+3.127_1 update x86_64 https://repo-default.voidlinux.org/current 273335 164765 +libarchive-3.8.9_1 update x86_64 https://repo-default.voidlinux.org/current 817232 380316 +libcrypto3-3.6.5_1 update x86_64 https://repo-default.voidlinux.org/current 6443040 2193502 +libssl3-3.6.5_1 update x86_64 https://repo-default.voidlinux.org/current 1065048 444864 +openssl-3.6.5_1 update x86_64 https://repo-default.voidlinux.org/current 2270125 635762 diff --git a/agent/test-data/package_updates/xbps_void_query_installed.txt b/agent/test-data/package_updates/xbps_void_query_installed.txt new file mode 100644 index 000000000..ae4459607 --- /dev/null +++ b/agent/test-data/package_updates/xbps_void_query_installed.txt @@ -0,0 +1,29 @@ +ii acl-2.4.0_1 Access Control List filesystem support +ii attr-2.6.0_1 Extended attribute support library for ACL support +ii base-files-0.146_1 Void Linux base system files +ii bzip2-1.0.8_2 Freely available, patent free, high-quality data compressor +ii ca-certificates-20250419+3.127_1 Common CA certificates for SSL/TLS from Mozilla +ii coreutils-9.4_3 GNU core utilities +ii dash-0.5.12_3 POSIX-compliant Unix shell, much smaller than GNU bash +ii gawk-5.3.2_1 GNU awk utility +ii glibc-2.41_1 GNU C library +ii gmp-6.3.0_1 Library for arbitrary precision arithmetic +ii grep-3.12_1 GNU grep utility +ii libarchive-3.8.9_1 Library to read/write several different streaming archive formats +ii libcap-2.78_1 POSIX.1e capabilities suite +ii libcrypto3-3.6.5_1 Toolkit for Secure Sockets Layer and Transport Layer Security - crypto library +ii liblz4-1.10.0_1 LZ4 compression library +ii liblzma-5.8.3_1 XZ-format compression library +ii libpcre2-10.47_1 Perl Compatible Regular Expressions (2nd version) - shared libraries +ii libreadline8-8.2.010_1 GNU Readline Library +ii libssl3-3.6.5_1 Toolkit for Secure Sockets Layer and Transport Layer Security - SSL/TLS library +ii libxbps-0.60.7_1 XBPS package system utilities - runtime library +ii libxcrypt-compat-4.4.38_1 Modern library for one-way hashing of passwords - legacy compatibility +ii libzstd-1.5.7_1 Fast real-time compression algorithm +ii ncurses-libs-6.6_1 System V Release 4.0 curses emulation library -- shared libraries +ii openssl-3.6.5_1 Toolkit for Secure Sockets Layer and Transport Layer Security +ii run-parts-5.23.2_1 Run scripts or programs in a directory +ii sed-4.10_1 GNU stream editor +ii xbps-0.60.7_1 XBPS package system utilities +ii xbps-triggers-0.131_1 XBPS triggers for Void Linux +ii zlib-1.3.2_1 Compression/decompression Library diff --git a/internal/site/src/lib/package-updates.test.ts b/internal/site/src/lib/package-updates.test.ts index ade1be049..191f37840 100644 --- a/internal/site/src/lib/package-updates.test.ts +++ b/internal/site/src/lib/package-updates.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test" import { classifyVersionChange } from "./package-updates" -// version pairs are taken from real apt, dnf, zypper, pacman and apk output +// version pairs are taken from real apt, dnf, zypper, pacman, apk and xbps output test("major, minor and patch use the first differing upstream component", () => { expect(classifyVersionChange("1.10.7-1", "2.0.0-1")).toBe("major") expect(classifyVersionChange("1.10.7-1", "1.11.0-1")).toBe("minor") @@ -10,6 +10,7 @@ test("major, minor and patch use the first differing upstream component", () => expect(classifyVersionChange("0.21.7-1", "0.21.8.2-1")).toBe("patch") expect(classifyVersionChange("3.3.0-r2", "3.3.7-r0")).toBe("patch") expect(classifyVersionChange("0.7.36-2.fc42", "0.7.37-2.fc42")).toBe("patch") + expect(classifyVersionChange("26.2.3_1", "26.2.4_1")).toBe("patch") // missing components count as zero expect(classifyVersionChange("1.2", "1.2.1")).toBe("patch") expect(classifyVersionChange("1.2", "1.3.0")).toBe("minor") @@ -32,6 +33,7 @@ test("revision-only changes", () => { expect(classifyVersionChange("1.36.1-r28", "1.36.1-r31")).toBe("revision") expect(classifyVersionChange("4.4-150400.25.22", "4.4-150400.27.3.2")).toBe("revision") expect(classifyVersionChange("42-30", "42-31")).toBe("revision") + expect(classifyVersionChange("12.3.2_1", "12.3.2_2")).toBe("revision") expect( classifyVersionChange("84.87+git20180409.04c9dae-150300.10.20.1", "84.87+git20180409.04c9dae-150300.10.23.1") ).toBe("revision") diff --git a/internal/site/src/lib/package-updates.ts b/internal/site/src/lib/package-updates.ts index 775bdfe2c..a938ebf94 100644 --- a/internal/site/src/lib/package-updates.ts +++ b/internal/site/src/lib/package-updates.ts @@ -16,7 +16,8 @@ interface SplitVersion { /** * Splits a distro version string into epoch, upstream version and packaging revision. * Works for the Debian ("1:2.3.4-1ubuntu1"), RPM ("2:9.2.390-1.fc42"), pacman ("1.3.7-1") - * and apk ("1.2.5-r3") formats. The revision follows the last "-". + * apk ("1.2.5-r3") and xbps ("26.2.4_1") formats. The revision follows the last "-", + * or the last "_" for xbps, whose versions have no "-". */ function splitVersion(version: string): SplitVersion { let epoch = 0 @@ -25,9 +26,12 @@ function splitVersion(version: string): SplitVersion { epoch = Number(epochMatch[1]) version = version.slice(epochMatch[0].length) } - const dash = version.lastIndexOf("-") - if (dash > 0) { - return { epoch, upstream: version.slice(0, dash), revision: version.slice(dash + 1) } + let sep = version.lastIndexOf("-") + if (sep < 0) { + sep = version.lastIndexOf("_") + } + if (sep > 0) { + return { epoch, upstream: version.slice(0, sep), revision: version.slice(sep + 1) } } return { epoch, upstream: version, revision: "" } }