LCOV - code coverage report
Current view: top level - pkg/lib - packagekit.js Coverage Total Hit
Test: cockpit Lines: 72.0 % 268 193
Test Date: 2026-06-16 14:09:37

            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           55 : import cockpit from "cockpit";
       8              : import { superuser } from 'superuser';
       9              : 
      10           55 : const _ = cockpit.gettext;
      11              : 
      12              : // see https://github.com/PackageKit/PackageKit/blob/main/lib/packagekit-glib2/pk-enum.h
      13           55 : export const Enum = {
      14           55 :     EXIT_SUCCESS: 1,
      15           55 :     EXIT_FAILED: 2,
      16           55 :     EXIT_CANCELLED: 3,
      17           55 :     ROLE_REFRESH_CACHE: 13,
      18           55 :     ROLE_UPDATE_PACKAGES: 22,
      19           55 :     INFO_UNKNOWN: -1,
      20           55 :     INFO_LOW: 3,
      21           55 :     INFO_ENHANCEMENT: 4,
      22           55 :     INFO_NORMAL: 5,
      23           55 :     INFO_BUGFIX: 6,
      24           55 :     INFO_IMPORTANT: 7,
      25           55 :     INFO_SECURITY: 8,
      26           55 :     INFO_DOWNLOADING: 10,
      27           55 :     INFO_UPDATING: 11,
      28           55 :     INFO_INSTALLING: 12,
      29           55 :     INFO_REMOVING: 13,
      30           55 :     INFO_REINSTALLING: 19,
      31           55 :     INFO_DOWNGRADING: 20,
      32           55 :     STATUS_WAIT: 1,
      33           55 :     STATUS_DOWNLOAD: 8,
      34           55 :     STATUS_INSTALL: 9,
      35           55 :     STATUS_UPDATE: 10,
      36           55 :     STATUS_CLEANUP: 11,
      37           55 :     STATUS_SIGCHECK: 14,
      38           55 :     STATUS_WAITING_FOR_LOCK: 30,
      39           55 :     FILTER_INSTALLED: (1 << 2),
      40           55 :     FILTER_NOT_INSTALLED: (1 << 3),
      41           55 :     FILTER_NEWEST: (1 << 16),
      42           55 :     FILTER_ARCH: (1 << 18),
      43           55 :     FILTER_NOT_SOURCE: (1 << 21),
      44           55 :     ERROR_ALREADY_INSTALLED: 9,
      45           55 :     TRANSACTION_FLAG_SIMULATE: (1 << 2),
      46           55 : };
      47              : 
      48           55 : export const transactionInterface = "org.freedesktop.PackageKit.Transaction";
      49              : 
      50           55 : 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            1 : function dbus_client() {
      59            1 :     if (_dbus_client === null) {
      60            1 :         _dbus_client = cockpit.dbus("org.freedesktop.PackageKit", { superuser: "try", track: true });
      61            0 :         _dbus_client.addEventListener("close", () => {
      62            0 :             console.log("PackageKit went away from D-Bus");
      63            0 :             _dbus_client = null;
      64            0 :         });
      65            1 :     }
      66              : 
      67            1 :     return _dbus_client;
      68            1 : }
      69              : 
      70              : // Reconnect when privileges change
      71           53 : superuser.addEventListener("changed", () => { _dbus_client = null });
      72              : 
      73            0 : function debug() {
      74            0 :     if (window.debugging == 'all' || window.debugging?.includes('packagekit'))
      75            0 :         console.debug.apply(console, arguments);
      76            0 : }
      77              : 
      78              : /**
      79              :  * Call a PackageKit method
      80              :  */
      81            1 : export function call(objectPath, iface, method, args, opts) {
      82            1 :     return dbus_client().call(objectPath, iface, method, args, opts);
      83            1 : }
      84              : 
      85              : /**
      86              :  * Figure out whether PackageKit is available and usable
      87              :  */
      88              : export function detect() {
      89              :     function dbus_detect() {
      90              :         return call("/org/freedesktop/PackageKit", "org.freedesktop.DBus.Properties",
      91              :                     "Get", ["org.freedesktop.PackageKit", "VersionMajor"])
      92              :                 .then(() => true,
      93              :                       () => false);
      94              :     }
      95              : 
      96              :     return cockpit.spawn(["findmnt", "-T", "/usr", "-n", "-o", "VFS-OPTIONS"])
      97              :             .then(options => {
      98              :                 if (options.split(",").indexOf("ro") >= 0)
      99              :                     return false;
     100              :                 else
     101              :                     return dbus_detect();
     102              :             })
     103              :             .catch(dbus_detect);
     104              : }
     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            1 : export function watchTransaction(transactionPath, signalHandlers, notifyHandler) {
     114            1 :     const subscriptions = [];
     115            1 :     let notifyReturn;
     116            1 :     const client = dbus_client();
     117              : 
     118              :     // Listen for PackageKit crashes while the transaction runs
     119            0 :     function onClose(event, ex) {
     120            0 :         console.warn("PackageKit went away during transaction", transactionPath, ":", JSON.stringify(ex));
     121            0 :         if (signalHandlers.ErrorCode)
     122            0 :             signalHandlers.ErrorCode("close", _("PackageKit crashed"));
     123            0 :         if (signalHandlers.Finished)
     124            0 :             signalHandlers.Finished(Enum.EXIT_FAILED);
     125            0 :     }
     126            1 :     client.addEventListener("close", onClose);
     127              : 
     128            1 :     if (signalHandlers) {
     129            1 :         Object.keys(signalHandlers).forEach(handler => subscriptions.push(
     130            1 :             client.subscribe({ interface: transactionInterface, path: transactionPath, member: handler },
     131            1 :                              (path, iface, signal, args) => signalHandlers[handler](...args)))
     132            1 :         );
     133            1 :     }
     134              : 
     135            1 :     if (notifyHandler) {
     136            1 :         notifyReturn = client.watch(transactionPath);
     137            1 :         subscriptions.push(notifyReturn);
     138            1 :         client.addEventListener("notify", reply => {
     139            1 :             const iface = reply?.detail?.[transactionPath]?.[transactionInterface];
     140            1 :             if (iface)
     141            1 :                 notifyHandler(iface, transactionPath);
     142            1 :         });
     143            1 :     }
     144              : 
     145              :     // unsubscribe when transaction finished
     146            1 :     subscriptions.push(client.subscribe(
     147            1 :         { interface: transactionInterface, path: transactionPath, member: "Finished" },
     148            1 :         () => {
     149            1 :             subscriptions.map(s => s.remove());
     150            1 :             client.removeEventListener("close", onClose);
     151            1 :         })
     152            1 :     );
     153              : 
     154            1 :     return notifyReturn;
     155            1 : }
     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            1 : export function transaction(method, arglist, signalHandlers, notifyHandler) {
     182            1 :     return call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "CreateTransaction", [])
     183            1 :             .then(([transactionPath]) => {
     184            1 :                 if (!signalHandlers && !notifyHandler)
     185            1 :                     return transactionPath;
     186              : 
     187            1 :                 const watchPromise = watchTransaction(transactionPath, signalHandlers, notifyHandler) || Promise.resolve();
     188            1 :                 return watchPromise.then(() => {
     189            1 :                     if (method) {
     190            1 :                         return call(transactionPath, transactionInterface, method, arglist)
     191            1 :                                 .then(() => transactionPath);
     192            1 :                     } else {
     193            1 :                         return transactionPath;
     194            1 :                     }
     195            1 :                 });
     196            1 :             });
     197            1 : }
     198              : 
     199           55 : export class TransactionError extends Error {
     200            0 :     constructor(code, detail) {
     201            0 :         super(detail);
     202            0 :         this.detail = detail;
     203            0 :         this.code = code;
     204            0 :     }
     205           55 : }
     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            1 : export function cancellableTransaction(method, arglist, progress_cb, signalHandlers) {
     220            1 :     if (signalHandlers?.ErrorCode || signalHandlers?.Finished)
     221            1 :         throw Error("cancellableTransaction handles ErrorCode and Finished signals internally");
     222              : 
     223            1 :     return new Promise((resolve, reject) => {
     224            1 :         let cancelled = false;
     225            1 :         let status;
     226            1 :         let allow_wait_status = false;
     227            1 :         const progress_data = {
     228            1 :             waiting: false,
     229            1 :             percentage: 0,
     230            1 :             cancel: null
     231            1 :         };
     232              : 
     233            1 :         function changed(props, transaction_path) {
     234            0 :             function cancel() {
     235            0 :                 call(transaction_path, transactionInterface, "Cancel", []);
     236            0 :                 cancelled = true;
     237            0 :             }
     238              : 
     239            1 :             if (progress_cb) {
     240            1 :                 if ("Status" in props)
     241            1 :                     status = props.Status;
     242            1 :                 progress_data.waiting = allow_wait_status && (status === Enum.STATUS_WAIT || status === Enum.STATUS_WAITING_FOR_LOCK);
     243            1 :                 if ("AllowCancel" in props)
     244            1 :                     progress_data.cancel = props.AllowCancel ? cancel : null;
     245            1 :                 if ("Percentage" in props && props.Percentage <= 100)
     246            1 :                     progress_data.percentage = props.Percentage;
     247              : 
     248            1 :                 progress_cb(progress_data);
     249            1 :             }
     250            1 :         }
     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            1 :         window.setTimeout(() => {
     255            1 :             allow_wait_status = true;
     256            1 :             changed({});
     257            1 :         }, 1000);
     258              : 
     259            1 :         transaction(method, arglist,
     260            1 :                     Object.assign({
     261              :                         // avoid calling progress_cb after ending the transaction, to avoid flickering cancel buttons
     262            0 :                         ErrorCode: (code, detail) => {
     263            0 :                             progress_cb = null;
     264            0 :                             reject(new TransactionError(cancelled ? "cancelled" : code, detail));
     265            0 :                         },
     266            1 :                         Finished: exit => {
     267            1 :                             progress_cb = null;
     268            1 :                             resolve(exit);
     269            1 :                         },
     270            1 :                     }, signalHandlers || {}),
     271            1 :                     changed)
     272            0 :                 .catch(ex => {
     273            0 :                     progress_cb = null;
     274            0 :                     reject(ex);
     275            0 :                 });
     276            1 :     });
     277            1 : }
     278              : 
     279              : /**
     280              :  * Refresh PackageKit Cache
     281              :  * @param {boolean} force - force refresh the cache (expensive)
     282              :  * @param {*} progress_cb - progress callback
     283              :  */
     284            1 : export function refresh(force = false, progress_cb) {
     285            1 :     return cancellableTransaction("RefreshCache", [force], progress_cb);
     286            1 : }
     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            0 : function removeHeading(text) {
     295            0 :     if (text)
     296            0 :         return text.trim().replace(/^== .* ==\n/, "")
     297            0 :                 .trim();
     298            0 :     return text;
     299            0 : }
     300              : 
     301              : // parse CVEs from an arbitrary text (changelog) and return URL array
     302            0 : function parseCVEs(text) {
     303            0 :     if (!text)
     304            0 :         return [];
     305              : 
     306            0 :     const cves = text.match(/CVE-\d{4}-\d+/g);
     307            0 :     if (!cves)
     308            0 :         return [];
     309            0 :     return cves.map(n => "https://www.cve.org/CVERecord?id=" + n);
     310            0 : }
     311              : 
     312            0 : function deduplicate(list) {
     313            0 :     return [...new Set(list)].sort();
     314            0 : }
     315              : 
     316              : /** @returns {Promise<void>} */
     317            1 : function loadUpdateDetailsBatch(pkg_ids, update_details, progress_cb) {
     318            1 :     return cancellableTransaction("GetUpdateDetail", [pkg_ids], progress_cb, {
     319            0 :         UpdateDetail: (packageId, _updates, _obsoletes, vendor_urls, bug_urls, cve_urls, _restart,
     320            0 :             update_text, changelog /* state, issued, updated */) => {
     321            0 :             const u = update_details[packageId];
     322            0 :             if (!u) {
     323            0 :                 console.warn("Mismatching update:", packageId);
     324            0 :                 return;
     325            0 :             }
     326              : 
     327            0 :             u.vendor_urls = vendor_urls;
     328            0 :             u.description = removeHeading(update_text) || changelog;
     329            0 :             if (update_text)
     330            0 :                 u.markdown = true;
     331              : 
     332            0 :             u.bug_urls = deduplicate(bug_urls);
     333              :             // many backends don't support proper severities; parse CVEs from description as a fallback
     334            0 :             u.cve_urls = deduplicate(cve_urls && cve_urls.length > 0 ? cve_urls : parseCVEs(u.description));
     335            0 :             if (u.cve_urls && u.cve_urls.length > 0)
     336            0 :                 u.severity = Severity.CRITICAL;
     337            0 :             u.vendor_urls = vendor_urls || [];
     338              :             // u.restart = restart; // broken (always "1") at least in Fedora
     339            0 :             debug("UpdateDetail:", u);
     340            0 :         }
     341            1 :     });
     342            1 : }
     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            1 : export async function get_updates(details, progress_cb) {
     352            1 :     const updates = {};
     353              : 
     354            1 :     await cancellableTransaction(
     355            1 :         "GetUpdates", [0],
     356            1 :         progress_cb,
     357            1 :         {
     358            1 :             Package: (info, packageId, summary) => {
     359              :                 // HACK: security updates have 0x50008 with PackageKit 1.2.8, so just consider the lower 8 bits
     360            1 :                 info = info & 0xff;
     361            1 :                 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            1 :                 if (info < Enum.INFO_LOW || info > Enum.INFO_SECURITY)
     364            1 :                     info = Enum.INFO_NORMAL;
     365              : 
     366            1 :                 if (info == Enum.INFO_LOW)
     367            1 :                     info = Severity.LOW;
     368            1 :                 else if (info == Enum.INFO_ENHANCEMENT)
     369            1 :                     info = Severity.MODERATE;
     370            1 :                 else if (info == Enum.INFO_SECURITY)
     371            1 :                     info = Severity.CRITICAL;
     372            1 :                 else if (info >= Enum.INFO_NORMAL)
     373            1 :                     info = Severity.IMPORTANT;
     374              :                 else
     375            1 :                     info = Severity.MODERATE;
     376              : 
     377            1 :                 updates[packageId] = { id: packageId, name: id_fields[0], version: id_fields[1], severity: info, arch: id_fields[2], summary };
     378            1 :             }
     379            1 :         });
     380              : 
     381            1 :     const pkg_ids = Object.keys(updates);
     382              : 
     383            1 :     if (details && pkg_ids.length > 0) {
     384            1 :         const processBatch = async (remaining_ids, current_batch_size) => {
     385            1 :             if (remaining_ids.length === 0) {
     386            1 :                 return;
     387            1 :             }
     388              : 
     389            1 :             const batch = remaining_ids.slice(0, current_batch_size);
     390            1 :             const next_ids = remaining_ids.slice(current_batch_size);
     391              : 
     392            1 :             try {
     393            1 :                 await loadUpdateDetailsBatch(batch, updates, progress_cb);
     394              :                 // continue with next batch using same batch size
     395            1 :                 await processBatch(next_ids, current_batch_size);
     396            1 :             } catch (ex) {
     397            1 :                 console.warn("GetUpdateDetail failed with batch size", current_batch_size, ":", JSON.stringify(ex));
     398              : 
     399            1 :                 if (current_batch_size > 1) {
     400              :                     // Reduce batch size to 1 and retry
     401            1 :                     console.log("Reducing GetUpdateDetail batch size to 1 and retrying");
     402            1 :                     await processBatch(remaining_ids, 1);
     403            1 :                 } else {
     404              :                     // Even batch size 1 failed, skip this batch and continue
     405            1 :                     console.warn("Failed to load update details for package:", batch[0]);
     406            1 :                     await processBatch(next_ids, 1);
     407            1 :                 }
     408            1 :             }
     409            1 :         };
     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            1 :         await processBatch(pkg_ids, 500);
     415            1 :     }
     416              : 
     417            1 :     const results = [];
     418            1 :     Object.keys(updates).forEach(key => {
     419            1 :         results.push({ id: key, ...updates[key] });
     420            1 :     });
     421            1 :     return results;
     422            1 : }
     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            0 : export function update_packages(updates, progress_cb, transaction_path) {
     432            0 :     const update_ids = updates.map(update => update.id);
     433            0 :     if (transaction_path) {
     434            0 :         return call(transaction_path, transactionInterface, "UpdatePackages", [0, update_ids]);
     435            0 :     } else {
     436            0 :         return cancellableTransaction("UpdatePackages", [0, update_ids], progress_cb);
     437            0 :     }
     438            0 : }
        

Generated by: LCOV version 2.0-1