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