LCOV - code coverage report
Current view: top level - pkg/lib - packagekit.js Coverage Total Hit
Test: cockpit Lines: 93.3 % 283 264
Test Date: 2026-06-25 11:17:56

            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          284 : import cockpit from "cockpit";
       8              : import { superuser } from 'superuser';
       9              : 
      10          284 : const _ = cockpit.gettext;
      11              : 
      12              : // see https://github.com/PackageKit/PackageKit/blob/main/lib/packagekit-glib2/pk-enum.h
      13          284 : export const Enum = {
      14          284 :     EXIT_SUCCESS: 1,
      15          284 :     EXIT_FAILED: 2,
      16          284 :     EXIT_CANCELLED: 3,
      17          284 :     ROLE_REFRESH_CACHE: 13,
      18          284 :     ROLE_UPDATE_PACKAGES: 22,
      19          284 :     INFO_UNKNOWN: -1,
      20          284 :     INFO_LOW: 3,
      21          284 :     INFO_ENHANCEMENT: 4,
      22          284 :     INFO_NORMAL: 5,
      23          284 :     INFO_BUGFIX: 6,
      24          284 :     INFO_IMPORTANT: 7,
      25          284 :     INFO_SECURITY: 8,
      26          284 :     INFO_DOWNLOADING: 10,
      27          284 :     INFO_UPDATING: 11,
      28          284 :     INFO_INSTALLING: 12,
      29          284 :     INFO_REMOVING: 13,
      30          284 :     INFO_REINSTALLING: 19,
      31          284 :     INFO_DOWNGRADING: 20,
      32          284 :     STATUS_WAIT: 1,
      33          284 :     STATUS_DOWNLOAD: 8,
      34          284 :     STATUS_INSTALL: 9,
      35          284 :     STATUS_UPDATE: 10,
      36          284 :     STATUS_CLEANUP: 11,
      37          284 :     STATUS_SIGCHECK: 14,
      38          284 :     STATUS_WAITING_FOR_LOCK: 30,
      39          284 :     FILTER_INSTALLED: (1 << 2),
      40          284 :     FILTER_NOT_INSTALLED: (1 << 3),
      41          284 :     FILTER_NEWEST: (1 << 16),
      42          284 :     FILTER_ARCH: (1 << 18),
      43          284 :     FILTER_NOT_SOURCE: (1 << 21),
      44          284 :     ERROR_ALREADY_INSTALLED: 9,
      45          284 :     TRANSACTION_FLAG_SIMULATE: (1 << 2),
      46          284 : };
      47              : 
      48          284 : export const transactionInterface = "org.freedesktop.PackageKit.Transaction";
      49              : 
      50          284 : 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            6 :         _dbus_client.addEventListener("close", () => {
      62            6 :             console.log("PackageKit went away from D-Bus");
      63            6 :             _dbus_client = null;
      64            6 :         });
      65          120 :     }
      66              : 
      67          120 :     return _dbus_client;
      68          120 : }
      69              : 
      70              : // Reconnect when privileges change
      71          281 : 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           18 : export function watchTransaction(transactionPath, signalHandlers, notifyHandler) {
     114           18 :     const subscriptions = [];
     115           18 :     let notifyReturn;
     116           18 :     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           18 :     client.addEventListener("close", onClose);
     127              : 
     128           18 :     if (signalHandlers) {
     129           18 :         Object.keys(signalHandlers).forEach(handler => subscriptions.push(
     130           18 :             client.subscribe({ interface: transactionInterface, path: transactionPath, member: handler },
     131           18 :                              (path, iface, signal, args) => signalHandlers[handler](...args)))
     132           18 :         );
     133           18 :     }
     134              : 
     135           18 :     if (notifyHandler) {
     136           18 :         notifyReturn = client.watch(transactionPath);
     137           18 :         subscriptions.push(notifyReturn);
     138           18 :         client.addEventListener("notify", reply => {
     139           18 :             const iface = reply?.detail?.[transactionPath]?.[transactionInterface];
     140           18 :             if (iface)
     141           18 :                 notifyHandler(iface, transactionPath);
     142           18 :         });
     143           18 :     }
     144              : 
     145              :     // unsubscribe when transaction finished
     146           18 :     subscriptions.push(client.subscribe(
     147           18 :         { interface: transactionInterface, path: transactionPath, member: "Finished" },
     148           18 :         () => {
     149           18 :             subscriptions.map(s => s.remove());
     150           18 :             client.removeEventListener("close", onClose);
     151           18 :         })
     152           18 :     );
     153              : 
     154           18 :     return notifyReturn;
     155           18 : }
     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           18 :             .then(([transactionPath]) => {
     184           13 :                 if (!signalHandlers && !notifyHandler)
     185           13 :                     return transactionPath;
     186              : 
     187           18 :                 const watchPromise = watchTransaction(transactionPath, signalHandlers, notifyHandler) || Promise.resolve();
     188           18 :                 return watchPromise.then(() => {
     189           18 :                     if (method) {
     190           18 :                         return call(transactionPath, transactionInterface, method, arglist)
     191           18 :                                 .then(() => transactionPath);
     192            3 :                     } else {
     193            3 :                         return transactionPath;
     194            3 :                     }
     195           18 :                 });
     196           18 :             });
     197           19 : }
     198              : 
     199          284 : export class TransactionError extends Error {
     200            1 :     constructor(code, detail) {
     201            1 :         super(detail);
     202            1 :         this.detail = detail;
     203            1 :         this.code = code;
     204            1 :     }
     205          284 : }
     206              : 
     207              : /**
     208              :  * Run a long cancellable PackageKit transaction
     209              :  *
     210              :  * method (string): D-Bus method name on the https://www.freedesktop.org/software/PackageKit/gtk-doc/Transaction.html interface
     211              :  * arglist (array): "in" arguments of @method
     212              :  * progress_cb: Callback that receives a {waiting, percentage, cancel} object regularly; if cancel is not null, it can
     213              :  *              be called to cancel the current transaction. if wait is true, PackageKit is waiting for its lock (i. e.
     214              :  *              on another package operation)
     215              :  * signalHandlers, notifyHandler: As in method #transaction, but ErrorCode and Finished are handled internally
     216              :  * Returns: Promise that resolves when the transaction finished successfully, or rejects with TransactionError
     217              :  *          on failure.
     218              :  */
     219           19 : export function cancellableTransaction(method, arglist, progress_cb, signalHandlers) {
     220           19 :     if (signalHandlers?.ErrorCode || signalHandlers?.Finished)
     221            3 :         throw Error("cancellableTransaction handles ErrorCode and Finished signals internally");
     222              : 
     223           19 :     return new Promise((resolve, reject) => {
     224           19 :         let cancelled = false;
     225           19 :         let status;
     226           19 :         let allow_wait_status = false;
     227           19 :         const progress_data = {
     228           19 :             waiting: false,
     229           19 :             percentage: 0,
     230           19 :             cancel: null
     231           19 :         };
     232              : 
     233           18 :         function changed(props, transaction_path) {
     234            0 :             function cancel() {
     235            0 :                 call(transaction_path, transactionInterface, "Cancel", []);
     236            0 :                 cancelled = true;
     237            0 :             }
     238              : 
     239           12 :             if (progress_cb) {
     240           12 :                 if ("Status" in props)
     241           12 :                     status = props.Status;
     242            8 :                 progress_data.waiting = allow_wait_status && (status === Enum.STATUS_WAIT || status === Enum.STATUS_WAITING_FOR_LOCK);
     243           12 :                 if ("AllowCancel" in props)
     244            4 :                     progress_data.cancel = props.AllowCancel ? cancel : null;
     245           12 :                 if ("Percentage" in props && props.Percentage <= 100)
     246           12 :                     progress_data.percentage = props.Percentage;
     247              : 
     248           12 :                 progress_cb(progress_data);
     249           12 :             }
     250           18 :         }
     251              : 
     252              :         // We ignore STATUS_WAIT and friends during the first second of a transaction.  They
     253              :         // are always reported briefly even when a transaction doesn't really need to wait.
     254           18 :         window.setTimeout(() => {
     255           18 :             allow_wait_status = true;
     256           18 :             changed({});
     257           18 :         }, 1000);
     258              : 
     259           19 :         transaction(method, arglist,
     260           19 :                     Object.assign({
     261              :                         // avoid calling progress_cb after ending the transaction, to avoid flickering cancel buttons
     262            1 :                         ErrorCode: (code, detail) => {
     263            1 :                             progress_cb = null;
     264            0 :                             reject(new TransactionError(cancelled ? "cancelled" : code, detail));
     265            1 :                         },
     266           18 :                         Finished: exit => {
     267           18 :                             progress_cb = null;
     268           18 :                             resolve(exit);
     269           18 :                         },
     270           12 :                     }, signalHandlers || {}),
     271           19 :                     changed)
     272            0 :                 .catch(ex => {
     273            0 :                     progress_cb = null;
     274            0 :                     reject(ex);
     275            0 :                 });
     276           19 :     });
     277           19 : }
     278              : 
     279              : /**
     280              :  * Refresh PackageKit Cache
     281              :  * @param {boolean} force - force refresh the cache (expensive)
     282              :  * @param {*} progress_cb - progress callback
     283              :  */
     284           10 : export function refresh(force = false, progress_cb) {
     285           10 :     return cancellableTransaction("RefreshCache", [force], progress_cb);
     286           10 : }
     287              : 
     288              : /**
     289              :  * On Debian the update_text starts with "== version ==" which is
     290              :  * redundant; we don't want Markdown headings in the table
     291              :  *
     292              :  * @param {string} text - update_text to filter
     293              :  */
     294            3 : function removeHeading(text) {
     295            3 :     if (text)
     296            3 :         return text.trim().replace(/^== .* ==\n/, "")
     297            3 :                 .trim();
     298            0 :     return text;
     299            3 : }
     300              : 
     301              : // parse CVEs from an arbitrary text (changelog) and return URL array
     302            3 : function parseCVEs(text) {
     303            3 :     if (!text)
     304            0 :         return [];
     305              : 
     306            3 :     const cves = text.match(/CVE-\d{4}-\d+/g);
     307            3 :     if (!cves)
     308            3 :         return [];
     309            1 :     return cves.map(n => "https://www.cve.org/CVERecord?id=" + n);
     310            3 : }
     311              : 
     312            3 : function deduplicate(list) {
     313            3 :     return [...new Set(list)].sort();
     314            3 : }
     315              : 
     316              : /** @returns {Promise<void>} */
     317           15 : function loadUpdateDetailsBatch(pkg_ids, update_details, progress_cb) {
     318           15 :     return cancellableTransaction("GetUpdateDetail", [pkg_ids], progress_cb, {
     319            3 :         UpdateDetail: (packageId, _updates, _obsoletes, vendor_urls, bug_urls, cve_urls, _restart,
     320            3 :             update_text, changelog /* state, issued, updated */) => {
     321            3 :             const u = update_details[packageId];
     322            0 :             if (!u) {
     323            0 :                 console.warn("Mismatching update:", packageId);
     324            0 :                 return;
     325            0 :             }
     326              : 
     327            3 :             u.vendor_urls = vendor_urls;
     328            0 :             u.description = removeHeading(update_text) || changelog;
     329            3 :             if (update_text)
     330            3 :                 u.markdown = true;
     331              : 
     332            3 :             u.bug_urls = deduplicate(bug_urls);
     333              :             // many backends don't support proper severities; parse CVEs from description as a fallback
     334            2 :             u.cve_urls = deduplicate(cve_urls && cve_urls.length > 0 ? cve_urls : parseCVEs(u.description));
     335            3 :             if (u.cve_urls && u.cve_urls.length > 0)
     336            2 :                 u.severity = Severity.CRITICAL;
     337            0 :             u.vendor_urls = vendor_urls || [];
     338              :             // u.restart = restart; // broken (always "1") at least in Fedora
     339            3 :             debug("UpdateDetail:", u);
     340            3 :         }
     341           15 :     });
     342           15 : }
     343              : 
     344              : /**
     345              :  * Get Updates
     346              :  * updates = { id, name, version, arch }
     347              :  * with details
     348              :  * updates = { id, name, version, arch, severity, bug_urls, cve_urls, vendor_urls, description, markdown }
     349              :  * @param {boolean} details - fetch detailed package information (security information)
     350              :  */
     351           18 : export async function get_updates(details, progress_cb) {
     352           18 :     const updates = {};
     353              : 
     354           18 :     await cancellableTransaction(
     355           18 :         "GetUpdates", [0],
     356           18 :         progress_cb,
     357           18 :         {
     358           15 :             Package: (info, packageId, summary) => {
     359              :                 // HACK: security updates have 0x50008 with PackageKit 1.2.8, so just consider the lower 8 bits
     360           15 :                 info = info & 0xff;
     361           15 :                 const id_fields = packageId.split(";");
     362              :                 // HACK: dnf backend yields wrong severity with PK < 1.2.4 (https://github.com/PackageKit/PackageKit/issues/268)
     363            5 :                 if (info < Enum.INFO_LOW || info > Enum.INFO_SECURITY)
     364           12 :                     info = Enum.INFO_NORMAL;
     365              : 
     366           15 :                 if (info == Enum.INFO_LOW)
     367            2 :                     info = Severity.LOW;
     368           15 :                 else if (info == Enum.INFO_ENHANCEMENT)
     369            3 :                     info = Severity.MODERATE;
     370           15 :                 else if (info == Enum.INFO_SECURITY)
     371            3 :                     info = Severity.CRITICAL;
     372           14 :                 else if (info >= Enum.INFO_NORMAL)
     373            2 :                     info = Severity.IMPORTANT;
     374              :                 else
     375            2 :                     info = Severity.MODERATE;
     376              : 
     377           15 :                 updates[packageId] = { id: packageId, name: id_fields[0], version: id_fields[1], severity: info, arch: id_fields[2], summary };
     378           15 :             }
     379           18 :         });
     380              : 
     381           18 :     const pkg_ids = Object.keys(updates);
     382              : 
     383           16 :     if (details && pkg_ids.length > 0) {
     384           15 :         const processBatch = async (remaining_ids, current_batch_size) => {
     385           15 :             if (remaining_ids.length === 0) {
     386           15 :                 return;
     387           15 :             }
     388              : 
     389           15 :             const batch = remaining_ids.slice(0, current_batch_size);
     390           15 :             const next_ids = remaining_ids.slice(current_batch_size);
     391              : 
     392           15 :             try {
     393           15 :                 await loadUpdateDetailsBatch(batch, updates, progress_cb);
     394              :                 // continue with next batch using same batch size
     395           15 :                 await processBatch(next_ids, current_batch_size);
     396            3 :             } catch (ex) {
     397            3 :                 console.warn("GetUpdateDetail failed with batch size", current_batch_size, ":", JSON.stringify(ex));
     398              : 
     399            3 :                 if (current_batch_size > 1) {
     400              :                     // Reduce batch size to 1 and retry
     401            3 :                     console.log("Reducing GetUpdateDetail batch size to 1 and retrying");
     402            3 :                     await processBatch(remaining_ids, 1);
     403            3 :                 } else {
     404              :                     // Even batch size 1 failed, skip this batch and continue
     405            3 :                     console.warn("Failed to load update details for package:", batch[0]);
     406            3 :                     await processBatch(next_ids, 1);
     407            3 :                 }
     408            3 :             }
     409           15 :         };
     410              : 
     411              :         // Avoid exceeding cockpit-ws frame size, so batch the loading of details
     412              :         // if we run into https://issues.redhat.com/browse/RHEL-109779 then we need to fall back to load packages
     413              :         // individually
     414           16 :         await processBatch(pkg_ids, 500);
     415           16 :     }
     416              : 
     417           18 :     const results = [];
     418           15 :     Object.keys(updates).forEach(key => {
     419           15 :         results.push({ id: key, ...updates[key] });
     420           15 :     });
     421           18 :     return results;
     422           18 : }
     423              : 
     424              : /**
     425              :  * Update packages
     426              :  *
     427              :  * @param {any[]} updates - packages to update from get_updates()
     428              :  * @param {any} progress_cb - optional progress callback
     429              :  * @param {string | undefined} transaction_path - optional transaction_path to re-use an existing transaction
     430              :  */
     431           11 : export function update_packages(updates, progress_cb, transaction_path) {
     432           11 :     const update_ids = updates.map(update => update.id);
     433           11 :     if (transaction_path) {
     434           11 :         return call(transaction_path, transactionInterface, "UpdatePackages", [0, update_ids]);
     435            1 :     } else {
     436            1 :         return cancellableTransaction("UpdatePackages", [0, update_ids], progress_cb);
     437            1 :     }
     438           11 : }
        

Generated by: LCOV version 2.0-1