1/*
2 This file is part of the KDE libraries
3 SPDX-FileCopyrightText: 2000 Torben Weis <weis@kde.org>
4 SPDX-FileCopyrightText: 2006-2013 David Faure <faure@kde.org>
5 SPDX-FileCopyrightText: 2009 Michael Pyne <michael.pyne@kdemail.net>
6
7 SPDX-License-Identifier: LGPL-2.0-or-later
8*/
9
10#include "desktopexecparser.h"
11
12#ifdef WITH_QTDBUS
13#include "kiofuse_interface.h"
14#endif
15
16#include <KApplicationTrader>
17#include <KConfigGroup>
18#include <KDesktopFile>
19#include <KLocalizedString>
20#include <KMacroExpander>
21#include <KService>
22#include <KSharedConfig>
23#include <KShell>
24#include <kprotocolinfo.h> // KF6 TODO remove after moving hasSchemeHandler to OpenUrlJob
25
26#ifdef WITH_QTDBUS
27#include <QDBusConnection>
28#include <QDBusReply>
29#endif
30#include <QDir>
31#include <QFile>
32#include <QProcessEnvironment>
33#include <QStandardPaths>
34#include <QUrl>
35
36#include <config-kiocore.h> // KDE_INSTALL_FULL_LIBEXECDIR_KF
37
38#include "kiocoredebug.h"
39
40class KRunMX1 : public KMacroExpanderBase
41{
42public:
43 explicit KRunMX1(const KService &_service)
44 : KMacroExpanderBase(QLatin1Char('%'))
45 , service(_service)
46 {
47 }
48
49 bool hasUrls = false;
50 bool hasSpec = false;
51 bool hasError = false;
52
53protected:
54 int expandEscapedMacro(const QString &str, int pos, QStringList &ret) override;
55
56private:
57 const KService &service;
58};
59
60int KRunMX1::expandEscapedMacro(const QString &str, int pos, QStringList &ret)
61{
62 if (str.length() == pos + 1) {
63 // Internally, the stack of KMacroExpanderBase is empty and thus it thinks everything is successfully parsed
64 // This could be the case if the escape char "%" in this case, is at the end of the string
65 // See BUG: 495606
66 hasError = true;
67 return 0;
68 }
69 uint option = str[pos + 1].unicode();
70 switch (option) {
71 case 'c':
72 ret << service.name().replace(c: QLatin1Char('%'), after: QLatin1String("%%"));
73 break;
74 case 'k':
75 ret << service.entryPath().replace(c: QLatin1Char('%'), after: QLatin1String("%%"));
76 break;
77 case 'i':
78 ret << QStringLiteral("--icon") << service.icon().replace(c: QLatin1Char('%'), after: QLatin1String("%%"));
79 break;
80 case 'm':
81 // ret << "-miniicon" << service.icon().replace( '%', "%%" );
82 qCWarning(KIO_CORE) << "-miniicon isn't supported anymore (service" << service.name() << ')';
83 break;
84 case 'u':
85 case 'U':
86 hasUrls = true;
87 Q_FALLTHROUGH();
88 /* fallthrough */
89 case 'f':
90 case 'F':
91 case 'n':
92 case 'N':
93 case 'd':
94 case 'D':
95 case 'v':
96 hasSpec = true;
97 Q_FALLTHROUGH();
98 /* fallthrough */
99 default:
100 return -2; // subst with same and skip
101 }
102 return 2;
103}
104
105class KRunMX2 : public KMacroExpanderBase
106{
107public:
108 explicit KRunMX2(const QList<QUrl> &_urls)
109 : KMacroExpanderBase(QLatin1Char('%'))
110 , ignFile(false)
111 , urls(_urls)
112 {
113 }
114
115 bool ignFile;
116
117protected:
118 int expandEscapedMacro(const QString &str, int pos, QStringList &ret) override;
119
120private:
121 void subst(int option, const QUrl &url, QStringList &ret);
122
123 const QList<QUrl> &urls;
124};
125
126void KRunMX2::subst(int option, const QUrl &url, QStringList &ret)
127{
128 switch (option) {
129 case 'u':
130 ret << ((url.isLocalFile() && url.fragment().isNull() && url.query().isNull()) ? QDir::toNativeSeparators(pathName: url.toLocalFile()) : url.toString());
131 break;
132 case 'd':
133 ret << url.adjusted(options: QUrl::RemoveFilename).path();
134 break;
135 case 'f':
136 ret << QDir::toNativeSeparators(pathName: url.toLocalFile());
137 break;
138 case 'n':
139 ret << url.fileName();
140 break;
141 case 'v':
142 if (url.isLocalFile() && QFile::exists(fileName: url.toLocalFile())) {
143 ret << KDesktopFile(url.toLocalFile()).desktopGroup().readEntry(key: "Dev");
144 }
145 break;
146 }
147 return;
148}
149
150int KRunMX2::expandEscapedMacro(const QString &str, int pos, QStringList &ret)
151{
152 uint option = str[pos + 1].unicode();
153 switch (option) {
154 case 'f':
155 case 'u':
156 case 'n':
157 case 'd':
158 case 'v':
159 if (urls.isEmpty()) {
160 if (!ignFile) {
161 // qCDebug(KIO_CORE) << "No URLs supplied to single-URL service" << str;
162 }
163 } else if (urls.count() > 1) {
164 qCWarning(KIO_CORE) << urls.count() << "URLs supplied to single-URL service" << str;
165 } else {
166 subst(option, url: urls.first(), ret);
167 }
168 break;
169 case 'F':
170 case 'U':
171 case 'N':
172 case 'D':
173 option += 'a' - 'A';
174 for (const QUrl &url : urls) {
175 subst(option, url, ret);
176 }
177 break;
178 case '%':
179 ret = QStringList(QStringLiteral("%"));
180 break;
181 default:
182 return -2; // subst with same and skip
183 }
184 return 2;
185}
186
187QStringList KIO::DesktopExecParser::supportedProtocols(const KService &service)
188{
189 QStringList supportedProtocols = service.supportedProtocols();
190
191 KRunMX1 mx1(service);
192 QString exec = service.exec();
193 if (mx1.expandMacrosShellQuote(str&: exec) && !mx1.hasUrls) {
194 if (!supportedProtocols.isEmpty()) {
195 qCWarning(KIO_CORE) << service.entryPath() << "contains supported protocols but doesn't use %u or %U in its Exec line! This is inconsistent.";
196 }
197 return QStringList();
198 } else {
199 if (supportedProtocols.isEmpty()) {
200 // compat mode: assume KIO if not set and it's a KDE app (or a KDE service)
201 const QStringList categories = service.property<QStringList>(QStringLiteral("Categories"));
202 if (categories.contains(str: QLatin1String("KDE")) || !service.isApplication() || service.entryPath().isEmpty() /*temp service*/) {
203 supportedProtocols.append(QStringLiteral("KIO"));
204 } else { // if no KDE app, be a bit over-generic
205 supportedProtocols.append(QStringLiteral("http"));
206 supportedProtocols.append(QStringLiteral("https")); // #253294
207 supportedProtocols.append(QStringLiteral("ftp"));
208 }
209 }
210 }
211
212 // qCDebug(KIO_CORE) << "supportedProtocols:" << supportedProtocols;
213 return supportedProtocols;
214}
215
216bool KIO::DesktopExecParser::isProtocolInSupportedList(const QUrl &url, const QStringList &supportedProtocols)
217{
218 return url.isLocalFile() //
219 || supportedProtocols.contains(str: QLatin1String("KIO")) //
220 || supportedProtocols.contains(str: url.scheme(), cs: Qt::CaseInsensitive);
221}
222
223// We have up to two sources of data, for protocols not handled by KIO workers (so called "helper") :
224// 1) the exec line of the .protocol file, if there's one
225// 2) the application associated with x-scheme-handler/<protocol> if there's one
226bool KIO::DesktopExecParser::hasSchemeHandler(const QUrl &url) // KF6 TODO move to OpenUrlJob
227{
228 if (KProtocolInfo::isHelperProtocol(url)) {
229 return true;
230 }
231 const KService::Ptr service = KApplicationTrader::preferredService(mimeType: QLatin1String("x-scheme-handler/") + url.scheme());
232 if (service) {
233 qCDebug(KIO_CORE) << QLatin1String("preferred service for x-scheme-handler/") + url.scheme() << service->desktopEntryName();
234 }
235 return service;
236}
237
238class KIO::DesktopExecParserPrivate
239{
240public:
241 DesktopExecParserPrivate(const KService &_service, const QList<QUrl> &_urls)
242 : service(_service)
243 , urls(_urls)
244 , tempFiles(false)
245 {
246 }
247
248 bool isUrlSupported(const QUrl &url, const QStringList &supportedProtocols);
249
250 const KService &service;
251 QList<QUrl> urls;
252 bool tempFiles;
253 QString suggestedFileName;
254 QString m_errorString;
255};
256
257KIO::DesktopExecParser::DesktopExecParser(const KService &service, const QList<QUrl> &urls)
258 : d(new DesktopExecParserPrivate(service, urls))
259{
260}
261
262KIO::DesktopExecParser::~DesktopExecParser()
263{
264}
265
266void KIO::DesktopExecParser::setUrlsAreTempFiles(bool tempFiles)
267{
268 d->tempFiles = tempFiles;
269}
270
271void KIO::DesktopExecParser::setSuggestedFileName(const QString &suggestedFileName)
272{
273 d->suggestedFileName = suggestedFileName;
274}
275
276static const QString kioexecPath()
277{
278 QString kioexec = QCoreApplication::applicationDirPath() + QLatin1String("/kioexec");
279 if (!QFileInfo::exists(file: kioexec)) {
280 kioexec = QStringLiteral(KDE_INSTALL_FULL_LIBEXECDIR_KF "/kioexec");
281 }
282 Q_ASSERT(QFileInfo::exists(kioexec));
283 return kioexec;
284}
285
286static QString findNonExecutableProgram(const QString &executable)
287{
288 // Relative to current dir, or absolute path
289 const QFileInfo fi(executable);
290 if (fi.exists() && !fi.isExecutable()) {
291 return executable;
292 }
293
294#ifdef Q_OS_UNIX
295 // This is a *very* simplified version of QStandardPaths::findExecutable
296 const QStringList searchPaths = QString::fromLocal8Bit(ba: qgetenv(varName: "PATH")).split(sep: QDir::listSeparator(), behavior: Qt::SkipEmptyParts);
297 for (const QString &searchPath : searchPaths) {
298 const QString candidate = searchPath + QLatin1Char('/') + executable;
299 const QFileInfo fileInfo(candidate);
300 if (fileInfo.exists()) {
301 if (fileInfo.isExecutable()) {
302 qWarning() << "Internal program error. QStandardPaths::findExecutable couldn't find" << executable << "but our own logic found it at"
303 << candidate << ". Please report a bug at https://bugs.kde.org";
304 } else {
305 return candidate;
306 }
307 }
308 }
309#endif
310 return QString();
311}
312
313bool KIO::DesktopExecParserPrivate::isUrlSupported(const QUrl &url, const QStringList &protocols)
314{
315 if (KIO::DesktopExecParser::isProtocolInSupportedList(url, supportedProtocols: protocols)) {
316 return true;
317 }
318
319 // supportedProtocols() only checks whether the .desktop file has MimeType=x-scheme-handler/xxx
320 // We also want to check whether the app has been set as default/associated in mimeapps.list
321 const auto handlers = KApplicationTrader::queryByMimeType(mimeType: QLatin1String("x-scheme-handler/") + url.scheme());
322 for (const KService::Ptr &handler : handlers) {
323 if (handler->desktopEntryName() == service.desktopEntryName()) {
324 return true;
325 }
326 }
327
328 return false;
329}
330
331QStringList KIO::DesktopExecParser::resultingArguments() const
332{
333 QString exec = d->service.exec();
334 if (exec.isEmpty()) {
335 d->m_errorString = i18n("No Exec field in %1", d->service.entryPath());
336 qCWarning(KIO_CORE) << "No Exec field in" << d->service.entryPath();
337 return QStringList();
338 }
339
340 // Extract the name of the binary to execute from the full Exec line, to see if it exists
341 const QString binary = executablePath(execLine: exec);
342 QString executableFullPath;
343 if (!binary.isEmpty()) { // skip all this if the Exec line is a complex shell command
344 if (QDir::isRelativePath(path: binary)) {
345 // Resolve the executable to ensure that helpers in libexec are found.
346 // Too bad for commands that need a shell - they must reside in $PATH.
347 executableFullPath = QStandardPaths::findExecutable(executableName: binary);
348 if (executableFullPath.isEmpty()) {
349 executableFullPath = QFile::decodeName(KDE_INSTALL_FULL_LIBEXECDIR_KF "/") + binary;
350 }
351 } else {
352 executableFullPath = binary;
353 }
354
355 // Now check that the binary exists and has the executable flag
356 if (!QFileInfo(executableFullPath).isExecutable()) {
357 // Does it really not exist, or is it non-executable (on Unix)? (bug #415567)
358 const QString nonExecutable = findNonExecutableProgram(executable: binary);
359 if (nonExecutable.isEmpty()) {
360 d->m_errorString = i18n("Could not find the program '%1'", binary);
361 } else {
362 if (QDir::isRelativePath(path: binary)) {
363 d->m_errorString = i18n("The program '%1' was found at '%2' but it is missing executable permissions.", binary, nonExecutable);
364 } else {
365 d->m_errorString = i18n("The program '%1' is missing executable permissions.", nonExecutable);
366 }
367 }
368 return QStringList();
369 }
370 }
371
372 QStringList result;
373 bool appHasTempFileOption;
374
375 KRunMX1 mx1(d->service);
376 KRunMX2 mx2(d->urls);
377
378 if (!mx1.expandMacrosShellQuote(str&: exec) || mx1.hasError) { // Error in shell syntax
379 d->m_errorString = i18n("Syntax error in command %1 coming from %2", exec, d->service.entryPath());
380 qCWarning(KIO_CORE) << "Syntax error in command" << d->service.exec() << ", service" << d->service.name();
381 return QStringList();
382 }
383
384 // FIXME: the current way of invoking kioexec disables term and su use
385
386 // Check if we need "tempexec" (kioexec in fact)
387 appHasTempFileOption = d->tempFiles && d->service.property<bool>(QStringLiteral("X-KDE-HasTempFileOption"));
388 if (d->tempFiles && !appHasTempFileOption && d->urls.size()) {
389 result << kioexecPath() << QStringLiteral("--tempfiles") << exec;
390 if (!d->suggestedFileName.isEmpty()) {
391 result << QStringLiteral("--suggestedfilename");
392 result << d->suggestedFileName;
393 }
394 result += QUrl::toStringList(uris: d->urls);
395 return result;
396 }
397
398#ifdef WITH_QTDBUS
399 // Return true for non-KIO desktop files with explicit X-KDE-Protocols list, like vlc, for the special case below
400 auto isNonKIO = [this]() {
401 const QStringList protocols = d->service.property<QStringList>(QStringLiteral("X-KDE-Protocols"));
402 return !protocols.isEmpty() && !protocols.contains(str: QLatin1String("KIO"));
403 };
404
405 // Check if we need kioexec, or KIOFuse
406 bool useKioexec = false;
407
408 org::kde::KIOFuse::VFS kiofuse_iface(QStringLiteral("org.kde.KIOFuse"), QStringLiteral("/org/kde/KIOFuse"), QDBusConnection::sessionBus());
409 struct MountRequest {
410 QDBusPendingReply<QString> reply;
411 int urlIndex;
412 };
413 QList<MountRequest> requests;
414 requests.reserve(asize: d->urls.count());
415
416 const QStringList appSupportedProtocols = supportedProtocols(service: d->service);
417 for (int i = 0; i < d->urls.count(); ++i) {
418 const QUrl url = d->urls.at(i);
419 const bool supported = mx1.hasUrls ? d->isUrlSupported(url, protocols: appSupportedProtocols) : url.isLocalFile();
420 if (!supported) {
421 // If FUSE fails, and there is no scheme handler, we'll have to fallback to kioexec
422 useKioexec = true;
423 }
424
425 // NOTE: Some non-KIO apps may support the URLs (e.g. VLC supports smb://)
426 // but will not have the password if they are not in the URL itself.
427 // Hence convert URL to KIOFuse equivalent in case there is a password.
428 // \sa https://pointieststick.com/2018/01/17/videos-on-samba-shares/
429 // \sa https://bugs.kde.org/show_bug.cgi?id=330192
430 if (!supported || (!url.userName().isEmpty() && url.password().isEmpty() && isNonKIO())) {
431 requests.push_back(t: {.reply: kiofuse_iface.mountUrl(remoteUrl: url.toString()), .urlIndex: i});
432 }
433 }
434
435 for (auto &request : requests) {
436 request.reply.waitForFinished();
437 }
438 const bool fuseError = std::any_of(first: requests.cbegin(), last: requests.cend(), pred: [](const MountRequest &request) {
439 return request.reply.isError();
440 });
441
442 if (fuseError && useKioexec) {
443 // We need to run the app through kioexec
444 result << kioexecPath();
445 if (d->tempFiles) {
446 result << QStringLiteral("--tempfiles");
447 }
448 if (!d->suggestedFileName.isEmpty()) {
449 result << QStringLiteral("--suggestedfilename");
450 result << d->suggestedFileName;
451 }
452 result << exec;
453 result += QUrl::toStringList(uris: d->urls);
454 return result;
455 }
456
457 // At this point we know we're not using kioexec, so feel free to replace
458 // KIO URLs with their KIOFuse local path.
459 for (const auto &request : std::as_const(t&: requests)) {
460 if (!request.reply.isError()) {
461 d->urls[request.urlIndex] = QUrl::fromLocalFile(localfile: request.reply.value());
462 }
463 }
464#endif
465
466 if (appHasTempFileOption) {
467 exec += QLatin1String(" --tempfile");
468 }
469
470 // Did the user forget to append something like '%f'?
471 // If so, then assume that '%f' is the right choice => the application
472 // accepts only local files.
473 if (!mx1.hasSpec) {
474 exec += QLatin1String(" %f");
475 mx2.ignFile = true;
476 }
477
478 mx2.expandMacrosShellQuote(str&: exec); // syntax was already checked, so don't check return value
479
480 /*
481 1 = need_shell, 2 = terminal, 4 = su
482
483 0 << split(cmd)
484 1 << "sh" << "-c" << cmd
485 2 << split(term) << "-e" << split(cmd)
486 3 << split(term) << "-e" << "sh" << "-c" << cmd
487
488 4 << "kdesu" << "-u" << user << "-c" << cmd
489 5 << "kdesu" << "-u" << user << "-c" << ("sh -c " + quote(cmd))
490 6 << split(term) << "-e" << "su" << user << "-c" << cmd
491 7 << split(term) << "-e" << "su" << user << "-c" << ("sh -c " + quote(cmd))
492
493 "sh -c" is needed in the "su" case, too, as su uses the user's login shell, not sh.
494 this could be optimized with the -s switch of some su versions (e.g., debian linux).
495 */
496
497 if (d->service.terminal()) {
498 KConfigGroup cg(KSharedConfig::openConfig(), QStringLiteral("General"));
499 QString terminal = cg.readPathEntry(key: "TerminalApplication", QStringLiteral("konsole"));
500
501 const bool isKonsole = (terminal == QLatin1String("konsole"));
502 QStringList terminalParts = KShell::splitArgs(cmd: terminal);
503 QString terminalPath;
504 if (!terminalParts.isEmpty()) {
505 terminalPath = QStandardPaths::findExecutable(executableName: terminalParts.at(i: 0));
506 }
507
508 if (terminalPath.isEmpty()) {
509 d->m_errorString = i18n("Terminal %1 not found while trying to run %2", terminal, d->service.entryPath());
510 qCWarning(KIO_CORE) << "Terminal" << terminal << "not found, service" << d->service.name();
511 return QStringList();
512 }
513 terminalParts[0] = terminalPath;
514 terminal = KShell::joinArgs(args: terminalParts);
515 if (isKonsole) {
516 if (!d->service.workingDirectory().isEmpty()) {
517 terminal += QLatin1String(" --workdir ") + KShell::quoteArg(arg: d->service.workingDirectory());
518 }
519 terminal += QLatin1String(" -qwindowtitle '%c'");
520 if (!d->service.icon().isEmpty()) {
521 terminal += QLatin1String(" -qwindowicon ") + KShell::quoteArg(arg: d->service.icon().replace(c: QLatin1Char('%'), after: QLatin1String("%%")));
522 }
523 }
524 terminal += QLatin1Char(' ') + d->service.terminalOptions();
525 if (!mx1.expandMacrosShellQuote(str&: terminal) || mx1.hasError) {
526 d->m_errorString = i18n("Syntax error in command %1 while trying to run %2", terminal, d->service.entryPath());
527 qCWarning(KIO_CORE) << "Syntax error in command" << terminal << ", service" << d->service.name();
528 return QStringList();
529 }
530 mx2.expandMacrosShellQuote(str&: terminal);
531 result = KShell::splitArgs(cmd: terminal); // assuming that the term spec never needs a shell!
532 result << QStringLiteral("-e");
533 }
534
535 KShell::Errors err;
536 QStringList execlist = KShell::splitArgs(cmd: exec, flags: KShell::AbortOnMeta | KShell::TildeExpand, err: &err);
537 if (!executableFullPath.isEmpty()) {
538 execlist[0] = executableFullPath;
539 }
540
541 if (d->service.substituteUid()) {
542 if (d->service.terminal()) {
543 result << QStringLiteral("su");
544 } else {
545 QString kdesu = QFile::decodeName(KDE_INSTALL_FULL_LIBEXECDIR_KF "/kdesu");
546 if (!QFile::exists(fileName: kdesu)) {
547 kdesu = QStandardPaths::findExecutable(QStringLiteral("kdesu"));
548 }
549 if (!QFile::exists(fileName: kdesu)) {
550 // Insert kdesu as string so we show a nice warning: 'Could not launch kdesu'
551 result << QStringLiteral("kdesu");
552 return result;
553 } else {
554 result << kdesu << QStringLiteral("-u");
555 }
556 }
557
558 result << d->service.username() << QStringLiteral("-c");
559 if (err == KShell::FoundMeta) {
560 exec = QLatin1String("/bin/sh -c ") + KShell::quoteArg(arg: exec);
561 } else {
562 exec = KShell::joinArgs(args: execlist);
563 }
564 result << exec;
565 } else {
566 if (err == KShell::FoundMeta) {
567 result << QStringLiteral("/bin/sh") << QStringLiteral("-c") << exec;
568 } else {
569 result += execlist;
570 }
571 }
572
573 return result;
574}
575
576QString KIO::DesktopExecParser::errorMessage() const
577{
578 return d->m_errorString;
579}
580
581// static
582QString KIO::DesktopExecParser::executableName(const QString &execLine)
583{
584 const QString bin = executablePath(execLine);
585 return bin.mid(position: bin.lastIndexOf(c: QLatin1Char('/')) + 1);
586}
587
588// static
589QString KIO::DesktopExecParser::executablePath(const QString &execLine)
590{
591 // Remove parameters and/or trailing spaces.
592 const QStringList args = KShell::splitArgs(cmd: execLine, flags: KShell::AbortOnMeta | KShell::TildeExpand);
593 auto it = std::find_if(first: args.cbegin(), last: args.cend(), pred: [](const QString &arg) {
594 return !arg.contains(c: QLatin1Char('='));
595 });
596 return it != args.cend() ? *it : QString{};
597}
598

source code of kio/src/core/desktopexecparser.cpp