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 277 : 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 19 : const watchPromise = watchTransaction(transactionPath, signalHandlers, notifyHandler) || Promise.resolve();
188 19 : return watchPromise.then(() => {
189 19 : if (method) {
190 19 : return call(transactionPath, transactionInterface, method, arglist)
191 19 : .then(() => transactionPath);
192 3 : } else {
193 3 : return transactionPath;
194 3 : }
195 19 : });
196 19 : });
197 19 : }
198 :
199 282 : 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 282 : }
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 19 : 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 4 : progress_data.waiting = allow_wait_status && (status === Enum.STATUS_WAIT || status === Enum.STATUS_WAITING_FOR_LOCK);
243 12 : if ("AllowCancel" in props)
244 3 : progress_data.cancel = props.AllowCancel ? cancel : null;
245 12 : if ("Percentage" in props && props.Percentage <= 100)
246 4 : progress_data.percentage = props.Percentage;
247 :
248 12 : progress_cb(progress_data);
249 12 : }
250 19 : }
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 0 : ErrorCode: (code, detail) => {
263 0 : progress_cb = null;
264 0 : reject(new TransactionError(cancelled ? "cancelled" : code, detail));
265 0 : },
266 19 : Finished: exit => {
267 19 : progress_cb = null;
268 19 : resolve(exit);
269 19 : },
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 19 : export async function get_updates(details, progress_cb) {
352 19 : const updates = {};
353 :
354 19 : await cancellableTransaction(
355 19 : "GetUpdates", [0],
356 19 : progress_cb,
357 19 : {
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 19 : });
380 :
381 19 : 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 2 : } catch (ex) {
397 2 : console.warn("GetUpdateDetail failed with batch size", current_batch_size, ":", JSON.stringify(ex));
398 :
399 2 : if (current_batch_size > 1) {
400 : // Reduce batch size to 1 and retry
401 2 : console.log("Reducing GetUpdateDetail batch size to 1 and retrying");
402 2 : await processBatch(remaining_ids, 1);
403 2 : } else {
404 : // Even batch size 1 failed, skip this batch and continue
405 2 : console.warn("Failed to load update details for package:", batch[0]);
406 2 : await processBatch(next_ids, 1);
407 2 : }
408 2 : }
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 19 : const results = [];
418 15 : Object.keys(updates).forEach(key => {
419 15 : results.push({ id: key, ...updates[key] });
420 15 : });
421 19 : return results;
422 19 : }
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 : }
|