LCOV - code coverage report
Current view: top level - pkg/lib - packagekit.js Coverage Total Hit
Test: cockpit Lines: 90.5 % 284 257
Test Date: 2026-06-01 17:00:21

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2017, 2018 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6              : import { Severity } from "_internal/packagemanager-abstract";
       7          282 : import cockpit from "cockpit";
       8              : import { superuser } from 'superuser';
       9              : 
      10          282 : const _ = cockpit.gettext;
      11              : 
      12              : // see https://github.com/PackageKit/PackageKit/blob/main/lib/packagekit-glib2/pk-enum.h
      13          282 : export const Enum = {
      14          282 :     EXIT_SUCCESS: 1,
      15          282 :     EXIT_FAILED: 2,
      16          282 :     EXIT_CANCELLED: 3,
      17          282 :     ROLE_REFRESH_CACHE: 13,
      18          282 :     ROLE_UPDATE_PACKAGES: 22,
      19          282 :     INFO_UNKNOWN: -1,
      20          282 :     INFO_LOW: 3,
      21          282 :     INFO_ENHANCEMENT: 4,
      22          282 :     INFO_NORMAL: 5,
      23          282 :     INFO_BUGFIX: 6,
      24          282 :     INFO_IMPORTANT: 7,
      25          282 :     INFO_SECURITY: 8,
      26          282 :     INFO_DOWNLOADING: 10,
      27          282 :     INFO_UPDATING: 11,
      28          282 :     INFO_INSTALLING: 12,
      29          282 :     INFO_REMOVING: 13,
      30          282 :     INFO_REINSTALLING: 19,
      31          282 :     INFO_DOWNGRADING: 20,
      32          282 :     STATUS_WAIT: 1,
      33          282 :     STATUS_DOWNLOAD: 8,
      34          282 :     STATUS_INSTALL: 9,
      35          282 :     STATUS_UPDATE: 10,
      36          282 :     STATUS_CLEANUP: 11,
      37          282 :     STATUS_SIGCHECK: 14,
      38          282 :     STATUS_WAITING_FOR_LOCK: 30,
      39          282 :     FILTER_INSTALLED: (1 << 2),
      40          282 :     FILTER_NOT_INSTALLED: (1 << 3),
      41          282 :     FILTER_NEWEST: (1 << 16),
      42          282 :     FILTER_ARCH: (1 << 18),
      43          282 :     FILTER_NOT_SOURCE: (1 << 21),
      44          282 :     ERROR_ALREADY_INSTALLED: 9,
      45          282 :     TRANSACTION_FLAG_SIMULATE: (1 << 2),
      46          282 : };
      47              : 
      48          282 : export const transactionInterface = "org.freedesktop.PackageKit.Transaction";
      49              : 
      50          282 : let _dbus_client = null;
      51              : 
      52              : /**
      53              :  * Get PackageKit D-Bus client
      54              :  *
      55              :  * This will get lazily initialized and re-initialized after PackageKit
      56              :  * disconnects (due to a crash or idle timeout).
      57              :  */
      58          120 : function dbus_client() {
      59          120 :     if (_dbus_client === null) {
      60          120 :         _dbus_client = cockpit.dbus("org.freedesktop.PackageKit", { superuser: "try", track: true });
      61            3 :         _dbus_client.addEventListener("close", () => {
      62            3 :             console.log("PackageKit went away from D-Bus");
      63            3 :             _dbus_client = null;
      64            3 :         });
      65          120 :     }
      66              : 
      67          120 :     return _dbus_client;
      68          120 : }
      69              : 
      70              : // Reconnect when privileges change
      71          278 : superuser.addEventListener("changed", () => { _dbus_client = null });
      72              : 
      73            3 : function debug() {
      74            0 :     if (window.debugging == 'all' || window.debugging?.includes('packagekit'))
      75            0 :         console.debug.apply(console, arguments);
      76            3 : }
      77              : 
      78              : /**
      79              :  * Call a PackageKit method
      80              :  */
      81          120 : export function call(objectPath, iface, method, args, opts) {
      82          120 :     return dbus_client().call(objectPath, iface, method, args, opts);
      83          120 : }
      84              : 
      85              : /**
      86              :  * Figure out whether PackageKit is available and usable
      87              :  */
      88          101 : export function detect() {
      89          101 :     function dbus_detect() {
      90          101 :         return call("/org/freedesktop/PackageKit", "org.freedesktop.DBus.Properties",
      91          101 :                     "Get", ["org.freedesktop.PackageKit", "VersionMajor"])
      92          100 :                 .then(() => true,
      93            1 :                       () => false);
      94          101 :     }
      95              : 
      96          101 :     return cockpit.spawn(["findmnt", "-T", "/usr", "-n", "-o", "VFS-OPTIONS"])
      97          101 :             .then(options => {
      98          101 :                 if (options.split(",").indexOf("ro") >= 0)
      99           12 :                     return false;
     100              :                 else
     101          101 :                     return dbus_detect();
     102          101 :             })
     103          101 :             .catch(dbus_detect);
     104          101 : }
     105              : 
     106              : /**
     107              :  * Watch a running PackageKit transaction
     108              :  *
     109              :  * transactionPath (string): D-Bus object path of the PackageKit transaction
     110              :  * signalHandlers, notifyHandler: As in method #transaction
     111              :  * Returns: If notifyHandler is set, Cockpit promise that resolves when the watch got set up
     112              :  */
     113           19 : export function watchTransaction(transactionPath, signalHandlers, notifyHandler) {
     114           19 :     const subscriptions = [];
     115           19 :     let notifyReturn;
     116           19 :     const client = dbus_client();
     117              : 
     118              :     // Listen for PackageKit crashes while the transaction runs
     119            1 :     function onClose(event, ex) {
     120            1 :         console.warn("PackageKit went away during transaction", transactionPath, ":", JSON.stringify(ex));
     121            1 :         if (signalHandlers.ErrorCode)
     122            1 :             signalHandlers.ErrorCode("close", _("PackageKit crashed"));
     123            1 :         if (signalHandlers.Finished)
     124            1 :             signalHandlers.Finished(Enum.EXIT_FAILED);
     125            1 :     }
     126           19 :     client.addEventListener("close", onClose);
     127              : 
     128           19 :     if (signalHandlers) {
     129           19 :         Object.keys(signalHandlers).forEach(handler => subscriptions.push(
     130           19 :             client.subscribe({ interface: transactionInterface, path: transactionPath, member: handler },
     131           19 :                              (path, iface, signal, args) => signalHandlers[handler](...args)))
     132           19 :         );
     133           19 :     }
     134              : 
     135           19 :     if (notifyHandler) {
     136           19 :         notifyReturn = client.watch(transactionPath);
     137           19 :         subscriptions.push(notifyReturn);
     138           19 :         client.addEventListener("notify", reply => {
     139           19 :             const iface = reply?.detail?.[transactionPath]?.[transactionInterface];
     140           19 :             if (iface)
     141           19 :                 notifyHandler(iface, transactionPath);
     142           19 :         });
     143           19 :     }
     144              : 
     145              :     // unsubscribe when transaction finished
     146           19 :     subscriptions.push(client.subscribe(
     147           19 :         { interface: transactionInterface, path: transactionPath, member: "Finished" },
     148           19 :         () => {
     149           19 :             subscriptions.map(s => s.remove());
     150           19 :             client.removeEventListener("close", onClose);
     151           19 :         })
     152           19 :     );
     153              : 
     154           19 :     return notifyReturn;
     155           19 : }
     156              : 
     157              : /**
     158              :  * Run a PackageKit transaction
     159              :  *
     160              :  * method (string): D-Bus method name on the https://www.freedesktop.org/software/PackageKit/gtk-doc/Transaction.html interface
     161              :  *                  If undefined, only a transaction will be created without calling a method on it
     162              :  * arglist (array): "in" arguments of @method
     163              :  * signalHandlers (object): maps PackageKit.Transaction signal names to handlers
     164              :  * notifyHandler (function): handler for https://cockpit-project.org/guide/latest/cockpit-dbus.html#cockpit-dbus-onnotify
     165              :  *                           signals, called on property changes with (changed_properties, transaction_path)
     166              :  * Returns: Promise that resolves with transaction path on success, or rejects on an error
     167              :  *
     168              :  * Note that most often you don't really need the transaction path, but want to
     169              :  * listen to the "Finished" signal.
     170              :  *
     171              :  * Example:
     172              :  *     transaction("GetUpdates", [0], {
     173              :  *             Package: (info, packageId, _summary) => { ... },
     174              :  *             ErrorCode: (code, details) => { ... },
     175              :  *         },
     176              :  *         changedProps => { ... }  // notify handler
     177              :  *     )
     178              :  *        .then(transactionPath => { ... })
     179              :  *        .catch(ex => { handle exception });
     180              :  */
     181           19 : export function transaction(method, arglist, signalHandlers, notifyHandler) {
     182           19 :     return call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "CreateTransaction", [])
     183           19 :             .then(([transactionPath]) => {
     184           13 :                 if (!signalHandlers && !notifyHandler)
     185           13 :                     return transactionPath;
     186              : 
     187           18 :                 const watchPromise = watchTransaction(transactionPath, signalHandlers, notifyHandler) || Promise.resolve();
     188           19 :                 return watchPromise.then(() => {
     189              :                     // FIX: force set cache to 24h as some PK backends like dnf4 doesn't
     190              :                     // integrate with PK cache and shows cache as 0.
     191           19 :                     call(transactionPath, transactionInterface, "SetHints", ["cache-age=1"]);
     192           19 :                     if (method) {
     193           19 :                         return call(transactionPath, transactionInterface, method, arglist)
     194           19 :                                 .then(() => transactionPath);
     195            2 :                     } else {
     196            2 :                         return transactionPath;
     197            2 :                     }
     198           19 :                 });
     199           19 :             });
     200           19 : }
     201              : 
     202          282 : export class TransactionError extends Error {
     203            0 :     constructor(code, detail) {
     204            0 :         super(detail);
     205            0 :         this.detail = detail;
     206            0 :         this.code = code;
     207            0 :     }
     208          282 : }
     209              : 
     210              : /**
     211              :  * Run a long cancellable PackageKit transaction
     212              :  *
     213              :  * method (string): D-Bus method name on the https://www.freedesktop.org/software/PackageKit/gtk-doc/Transaction.html interface
     214              :  * arglist (array): "in" arguments of @method
     215              :  * progress_cb: Callback that receives a {waiting, percentage, cancel} object regularly; if cancel is not null, it can
     216              :  *              be called to cancel the current transaction. if wait is true, PackageKit is waiting for its lock (i. e.
     217              :  *              on another package operation)
     218              :  * signalHandlers, notifyHandler: As in method #transaction, but ErrorCode and Finished are handled internally
     219              :  * Returns: Promise that resolves when the transaction finished successfully, or rejects with TransactionError
     220              :  *          on failure.
     221              :  */
     222           19 : export function cancellableTransaction(method, arglist, progress_cb, signalHandlers) {
     223           19 :     if (signalHandlers?.ErrorCode || signalHandlers?.Finished)
     224            2 :         throw Error("cancellableTransaction handles ErrorCode and Finished signals internally");
     225              : 
     226           19 :     return new Promise((resolve, reject) => {
     227           19 :         let cancelled = false;
     228           19 :         let status;
     229           19 :         let allow_wait_status = false;
     230           19 :         const progress_data = {
     231           19 :             waiting: false,
     232           19 :             percentage: 0,
     233           19 :             cancel: null
     234           19 :         };
     235              : 
     236           19 :         function changed(props, transaction_path) {
     237            0 :             function cancel() {
     238            0 :                 call(transaction_path, transactionInterface, "Cancel", []);
     239            0 :                 cancelled = true;
     240            0 :             }
     241              : 
     242           11 :             if (progress_cb) {
     243           11 :                 if ("Status" in props)
     244           11 :                     status = props.Status;
     245            4 :                 progress_data.waiting = allow_wait_status && (status === Enum.STATUS_WAIT || status === Enum.STATUS_WAITING_FOR_LOCK);
     246           11 :                 if ("AllowCancel" in props)
     247            2 :                     progress_data.cancel = props.AllowCancel ? cancel : null;
     248           11 :                 if ("Percentage" in props && props.Percentage <= 100)
     249            3 :                     progress_data.percentage = props.Percentage;
     250              : 
     251           11 :                 progress_cb(progress_data);
     252           11 :             }
     253           19 :         }
     254              : 
     255              :         // We ignore STATUS_WAIT and friends during the first second of a transaction.  They
     256              :         // are always reported briefly even when a transaction doesn't really need to wait.
     257           17 :         window.setTimeout(() => {
     258           17 :             allow_wait_status = true;
     259           17 :             changed({});
     260           17 :         }, 1000);
     261              : 
     262           19 :         transaction(method, arglist,
     263           19 :                     Object.assign({
     264              :                         // avoid calling progress_cb after ending the transaction, to avoid flickering cancel buttons
     265            0 :                         ErrorCode: (code, detail) => {
     266            0 :                             progress_cb = null;
     267            0 :                             reject(new TransactionError(cancelled ? "cancelled" : code, detail));
     268            0 :                         },
     269           19 :                         Finished: exit => {
     270           19 :                             progress_cb = null;
     271           19 :                             resolve(exit);
     272           19 :                         },
     273           11 :                     }, signalHandlers || {}),
     274           19 :                     changed)
     275            0 :                 .catch(ex => {
     276            0 :                     progress_cb = null;
     277            0 :                     reject(ex);
     278            0 :                 });
     279           19 :     });
     280           19 : }
     281              : 
     282              : /**
     283              :  * Refresh PackageKit Cache
     284              :  * @param {boolean} force - force refresh the cache (expensive)
     285              :  * @param {*} progress_cb - progress callback
     286              :  */
     287            9 : export function refresh(force = false, progress_cb) {
     288            9 :     return cancellableTransaction("RefreshCache", [force], progress_cb);
     289            9 : }
     290              : 
     291              : /**
     292              :  * On Debian the update_text starts with "== version ==" which is
     293              :  * redundant; we don't want Markdown headings in the table
     294              :  *
     295              :  * @param {string} text - update_text to filter
     296              :  */
     297            3 : function removeHeading(text) {
     298            3 :     if (text)
     299            3 :         return text.trim().replace(/^== .* ==\n/, "")
     300            3 :                 .trim();
     301            0 :     return text;
     302            3 : }
     303              : 
     304              : // parse CVEs from an arbitrary text (changelog) and return URL array
     305            3 : function parseCVEs(text) {
     306            3 :     if (!text)
     307            0 :         return [];
     308              : 
     309            3 :     const cves = text.match(/CVE-\d{4}-\d+/g);
     310            3 :     if (!cves)
     311            3 :         return [];
     312            1 :     return cves.map(n => "https://www.cve.org/CVERecord?id=" + n);
     313            3 : }
     314              : 
     315            3 : function deduplicate(list) {
     316            3 :     return [...new Set(list)].sort();
     317            3 : }
     318              : 
     319              : /** @returns {Promise<void>} */
     320           15 : function loadUpdateDetailsBatch(pkg_ids, update_details, progress_cb) {
     321           15 :     return cancellableTransaction("GetUpdateDetail", [pkg_ids], progress_cb, {
     322            3 :         UpdateDetail: (packageId, _updates, _obsoletes, vendor_urls, bug_urls, cve_urls, _restart,
     323            3 :             update_text, changelog /* state, issued, updated */) => {
     324            3 :             const u = update_details[packageId];
     325            0 :             if (!u) {
     326            0 :                 console.warn("Mismatching update:", packageId);
     327            0 :                 return;
     328            0 :             }
     329              : 
     330            3 :             u.vendor_urls = vendor_urls;
     331            0 :             u.description = removeHeading(update_text) || changelog;
     332            3 :             if (update_text)
     333            3 :                 u.markdown = true;
     334              : 
     335            3 :             u.bug_urls = deduplicate(bug_urls);
     336              :             // many backends don't support proper severities; parse CVEs from description as a fallback
     337            2 :             u.cve_urls = deduplicate(cve_urls && cve_urls.length > 0 ? cve_urls : parseCVEs(u.description));
     338            3 :             if (u.cve_urls && u.cve_urls.length > 0)
     339            2 :                 u.severity = Severity.CRITICAL;
     340            0 :             u.vendor_urls = vendor_urls || [];
     341              :             // u.restart = restart; // broken (always "1") at least in Fedora
     342            3 :             debug("UpdateDetail:", u);
     343            3 :         }
     344           15 :     });
     345           15 : }
     346              : 
     347              : /**
     348              :  * Get Updates
     349              :  * updates = { id, name, version, arch }
     350              :  * with details
     351              :  * updates = { id, name, version, arch, severity, bug_urls, cve_urls, vendor_urls, description, markdown }
     352              :  * @param {boolean} details - fetch detailed package information (security information)
     353              :  */
     354           19 : export async function get_updates(details, progress_cb) {
     355           19 :     const updates = {};
     356              : 
     357           19 :     await cancellableTransaction(
     358           19 :         "GetUpdates", [0],
     359           19 :         progress_cb,
     360           19 :         {
     361           15 :             Package: (info, packageId, summary) => {
     362              :                 // HACK: security updates have 0x50008 with PackageKit 1.2.8, so just consider the lower 8 bits
     363           15 :                 info = info & 0xff;
     364           15 :                 const id_fields = packageId.split(";");
     365              :                 // HACK: dnf backend yields wrong severity with PK < 1.2.4 (https://github.com/PackageKit/PackageKit/issues/268)
     366            4 :                 if (info < Enum.INFO_LOW || info > Enum.INFO_SECURITY)
     367           12 :                     info = Enum.INFO_NORMAL;
     368              : 
     369           15 :                 if (info == Enum.INFO_LOW)
     370            1 :                     info = Severity.LOW;
     371           15 :                 else if (info == Enum.INFO_ENHANCEMENT)
     372            2 :                     info = Severity.MODERATE;
     373           15 :                 else if (info == Enum.INFO_SECURITY)
     374            2 :                     info = Severity.CRITICAL;
     375           14 :                 else if (info >= Enum.INFO_NORMAL)
     376            1 :                     info = Severity.IMPORTANT;
     377              :                 else
     378            1 :                     info = Severity.MODERATE;
     379              : 
     380           15 :                 updates[packageId] = { id: packageId, name: id_fields[0], version: id_fields[1], severity: info, arch: id_fields[2], summary };
     381           15 :             }
     382           19 :         });
     383              : 
     384           18 :     const pkg_ids = Object.keys(updates);
     385              : 
     386           16 :     if (details && pkg_ids.length > 0) {
     387           15 :         const processBatch = async (remaining_ids, current_batch_size) => {
     388           15 :             if (remaining_ids.length === 0) {
     389           15 :                 return;
     390           15 :             }
     391              : 
     392           15 :             const batch = remaining_ids.slice(0, current_batch_size);
     393           15 :             const next_ids = remaining_ids.slice(current_batch_size);
     394              : 
     395           15 :             try {
     396           15 :                 await loadUpdateDetailsBatch(batch, updates, progress_cb);
     397              :                 // continue with next batch using same batch size
     398           15 :                 await processBatch(next_ids, current_batch_size);
     399            1 :             } catch (ex) {
     400            1 :                 console.warn("GetUpdateDetail failed with batch size", current_batch_size, ":", JSON.stringify(ex));
     401              : 
     402            1 :                 if (current_batch_size > 1) {
     403              :                     // Reduce batch size to 1 and retry
     404            1 :                     console.log("Reducing GetUpdateDetail batch size to 1 and retrying");
     405            1 :                     await processBatch(remaining_ids, 1);
     406            1 :                 } else {
     407              :                     // Even batch size 1 failed, skip this batch and continue
     408            1 :                     console.warn("Failed to load update details for package:", batch[0]);
     409            1 :                     await processBatch(next_ids, 1);
     410            1 :                 }
     411            1 :             }
     412           15 :         };
     413              : 
     414              :         // Avoid exceeding cockpit-ws frame size, so batch the loading of details
     415              :         // if we run into https://issues.redhat.com/browse/RHEL-109779 then we need to fall back to load packages
     416              :         // individually
     417           16 :         await processBatch(pkg_ids, 500);
     418           16 :     }
     419              : 
     420           18 :     const results = [];
     421           15 :     Object.keys(updates).forEach(key => {
     422           15 :         results.push({ id: key, ...updates[key] });
     423           15 :     });
     424           18 :     return results;
     425           19 : }
     426              : 
     427              : /**
     428              :  * Update packages
     429              :  *
     430              :  * @param {any[]} updates - packages to update from get_updates()
     431              :  * @param {any} progress_cb - optional progress callback
     432              :  * @param {string | undefined} transaction_path - optional transaction_path to re-use an existing transaction
     433              :  */
     434           12 : export function update_packages(updates, progress_cb, transaction_path) {
     435           12 :     const update_ids = updates.map(update => update.id);
     436           12 :     if (transaction_path) {
     437           12 :         return call(transaction_path, transactionInterface, "UpdatePackages", [0, update_ids]);
     438            1 :     } else {
     439            1 :         return cancellableTransaction("UpdatePackages", [0, update_ids], progress_cb);
     440            1 :     }
     441           12 : }
        

Generated by: LCOV version 2.0-1