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 : }
|