diff --git a/apps/app-identifier/src/CMakeLists.txt b/apps/app-identifier/src/CMakeLists.txt index e751fecd..7cce33b0 100644 --- a/apps/app-identifier/src/CMakeLists.txt +++ b/apps/app-identifier/src/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(${BIN_NAME} main.cpp) target_link_libraries(${BIN_NAME} PRIVATE Qt6::Core Qt6::DBus + Dtk6::Core ) target_include_directories(${BIN_NAME} PRIVATE diff --git a/apps/dde-am/src/launcher.cpp b/apps/dde-am/src/launcher.cpp index d9a4cffa..1daf71c6 100644 --- a/apps/dde-am/src/launcher.cpp +++ b/apps/dde-am/src/launcher.cpp @@ -185,7 +185,7 @@ QString Launcher::appId() const noexcept const auto endIndex = pathView.indexOf(u'/', startIndex + 1); const auto id = endIndex <= -1 ? pathView.sliced(startIndex + 1) : pathView.sliced(startIndex + 1, endIndex - (startIndex + 1)); - return unescapeFromObjectPath(id); + return DUtil::unescapeFromObjectPath(id.toString()); } void Launcher::setAutostart(bool autostart) diff --git a/apps/dde-am/src/main.cpp b/apps/dde-am/src/main.cpp index 06e26280..0bf7fc85 100644 --- a/apps/dde-am/src/main.cpp +++ b/apps/dde-am/src/main.cpp @@ -149,10 +149,10 @@ int handleLaunchApp(const QCommandLineParser &parser, QString appPath; if (!appId.isEmpty()) { // 解析出 appId,说明输入是 appId 或 desktop 文件 - appPath = fromStaticRaw(DDEApplicationManager1ObjectPath) % u'/' % escapeToObjectPath(appId); + appPath = fromStaticRaw(DDEApplicationManager1ObjectPath) % u'/' % DUtil::escapeToObjectPath(appId); } else if (!inputArg.isEmpty() && !inputArg.startsWith(u'/')) { // 既不是绝对路径,也不是 desktop 文件等,视为 appId - appPath = fromStaticRaw(DDEApplicationManager1ObjectPath) % u'/' % escapeToObjectPath(inputArg); + appPath = fromStaticRaw(DDEApplicationManager1ObjectPath) % u'/' % DUtil::escapeToObjectPath(inputArg); } else { // 绝对路径或特殊用法 qWarning() << "unknown input: " << inputArg << ", just use it as path."; diff --git a/debian/control b/debian/control index 6855a275..22c27dc7 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Build-Depends: debhelper-compat ( =12), cmake, pkg-config, qt6-base-dev, libdtkcommon-dev, - libdtk6core-dev, + libdtk6core-dev (>= 6.7.41), treeland-protocols, qt6-wayland-dev, qt6-base-private-dev, diff --git a/src/global.h b/src/global.h index 8f9726f8..6257d7ea 100644 --- a/src/global.h +++ b/src/global.h @@ -25,6 +25,7 @@ #include #include #include +#include Q_DECLARE_LOGGING_CATEGORY(DDEAMProf) Q_DECLARE_LOGGING_CATEGORY(DDEAMUtils) @@ -422,136 +423,98 @@ inline QLocale getUserLocale() namespace Detail { -inline bool isBaseAlnum(QChar ch) noexcept +inline QByteArray unescapeToBytes(const QStringView str, const QStringView prefix) noexcept { - const ushort cell = ch.unicode(); - return (cell >= u'a' && cell <= u'z') || (cell >= u'A' && cell <= u'Z') || (cell >= u'0' && cell <= u'9'); -} - -inline QString unescapeImpl(QStringView str, QStringView prefix) noexcept -{ - const auto prefixLen = prefix.size(); - const auto step = prefixLen + 2; - const auto len = str.size(); + const auto len = str.length(); + const auto prefixLen = prefix.length(); - auto r = str.indexOf(prefix); - if (r == -1 || r > len - step) { - return str.toString(); - } - - QString result; + QByteArray result; result.reserve(len); - result.append(str.first(r)); - while (r < len) { - if (r <= len - step && str.sliced(r, prefixLen) == prefix) { + for (qsizetype i = 0; i < len;) { + if (i <= len - prefixLen - 2 && str.mid(i, prefixLen) == prefix) { bool ok{false}; - const auto val = str.sliced(r + prefixLen, 2).toShort(&ok, 16); - + auto byte = static_cast(str.mid(i + prefixLen, 2).toUShort(&ok, 16)); if (ok) { - result.append(QChar::fromLatin1(static_cast(val))); - r += step; + result.append(static_cast(byte)); + i += prefixLen + 2; continue; } } - result.append(str.at(r++)); - } - - if (r < len) { - result.append(str.sliced(r)); + result.append(str.at(i).toLatin1()); + ++i; } + result.shrink_to_fit(); return result; } } // namespace Detail -inline QString escapeToObjectPath(QStringView str) +inline QString escapeApplicationId(QByteArrayView utf8) { - using namespace Qt::StringLiterals; - if (str.isEmpty()) { - return u"_"_s; - } - - // see: https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-marshaling-object-path - auto isSafe = [](QChar ch) { return Detail::isBaseAlnum(ch) || ch == u'_' || ch == u'/'; }; - - const auto *it = std::find_if_not(str.cbegin(), str.cend(), isSafe); - if (it == str.cend()) { - return str.toString(); - } - - QString result; - result.reserve(str.size() + 16); - result.append(str.first(std::distance(str.cbegin(), it))); - - for (; it != str.end(); ++it) { - const auto ch = *it; - if (isSafe(ch)) { - result.append(ch); - } else { - if (ch.row() != 0) { - qCWarning(DDEAMUtils).nospace() - << "Character U+" << Qt::hex << ch.unicode() << " is truncated to " << static_cast(ch.cell()); - } - - result.append(u'_'); - result.append(QString::number(ch.cell(), 16).rightJustified(2, u'0').toLower()); - } - } - - return result; -} - -inline QString unescapeFromObjectPath(QStringView str) -{ - using namespace Qt::StringLiterals; - return Detail::unescapeImpl(str, u"_"_s); -} - -inline QString escapeApplicationId(QStringView id) -{ - if (id.isEmpty()) { - return id.toString(); + if (utf8.isEmpty()) { + return {}; } using namespace Qt::StringLiterals; // see: // https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#String%20Escaping%20for%20Inclusion%20in%20Unit%20Names - auto isSafeChar = [](QChar ch) { return Detail::isBaseAlnum(ch) || ch == u'_' || ch == u':' || ch == u'.'; }; + // + // This function escapes UTF-8 byte sequences to valid systemd unit names. + // Each non-safe byte is escaped as "\xxx" (2-digit lowercase hex). + // Safe characters are: [A-Z][a-z][0-9]:_. + // '/' is replaced by '-' (special handling for path-style IDs) + // '.' at the start is escaped; other '.' are safe - // Custom escape that handles "/" specially QString result; - result.reserve(id.size() + 16); + result.reserve(utf8.size() * 4); // Worst case: every byte needs "\xxx" (4 chars) + + bool hasNonAscii{false}; - for (qsizetype i = 0; i < id.size(); ++i) { - const auto ch = id.at(i); + for (qsizetype i = 0; i < utf8.size(); ++i) { + auto byte = static_cast(utf8.at(i)); - if (ch == u'/') { - // "/" is replaced by "-" + if (byte == '/') { result.append(u'-'); - } else if (i == 0 && ch == u'.') { - // "." as first char is escaped + } else if (i == 0 && byte == '.') { result.append(uR"(\x)"_s); - result.append(QString::number(ch.cell(), 16).rightJustified(2, u'0').toLower()); - } else if (isSafeChar(ch)) { - result.append(ch); + result.append(QString::number(byte, 16).rightJustified(2, u'0').toLower()); + } else if (std::isalnum(byte) || byte == ':' || byte == '_' || byte == '.') { + result.append(QChar::fromLatin1(byte)); } else { - // Other non-safe chars are escaped + if (byte > 0x7F) { + hasNonAscii = true; + } + result.append(uR"(\x)"_s); - result.append(QString::number(ch.cell(), 16).rightJustified(2, u'0').toLower()); + result.append(QString::number(byte, 16).rightJustified(2, u'0').toLower()); } } + if (hasNonAscii) { + qCWarning(DDEAMUtils) << "String contains non-ASCII characters, which " + "does not conform to the systemd specification. A " + "compatible ID is generated using UTF-8 byte escaping for now," + "but this compatibility may be removed in the future." + "Please use ASCII-only names."; + } + + result.shrink_to_fit(); return result; } +inline QString escapeApplicationId(QStringView str) +{ + return escapeApplicationId(str.toUtf8()); +} + inline QString unescapeApplicationId(QStringView id) { using namespace Qt::StringLiterals; - return Detail::unescapeImpl(id, uR"(\x)"_s); + return QString::fromUtf8(Detail::unescapeToBytes(id, uR"(\x)"_s)); } inline QString getRelativePathFromAppId(QStringView id) @@ -788,7 +751,7 @@ inline QByteArray getCurrentSessionId() auto msg = QDBusMessage::createMethodCall(QString::fromUtf8(SystemdService), QString::fromUtf8(SystemdObjectPath) % u"/unit/"_s % - escapeToObjectPath(u"graphical-session.target"), + DUtil::escapeToObjectPath(u"graphical-session.target"_s), fromStaticRaw(SystemdPropInterfaceName), u"Get"_s); msg << fromStaticRaw(SystemdUnitInterfaceName); @@ -880,7 +843,7 @@ inline QString getObjectPathFromAppId(const QString &appId) { const auto &basePath = fromStaticRaw(DDEApplicationManager1ObjectPath); if (!appId.isEmpty()) { - return basePath % '/' % escapeToObjectPath(appId); + return basePath % '/' % DUtil::escapeToObjectPath(appId); } return basePath % '/' % QUuid::createUuid().toString(QUuid::Id128); diff --git a/tests/ut_escape.cpp b/tests/ut_escape.cpp index af8a6298..95a146af 100644 --- a/tests/ut_escape.cpp +++ b/tests/ut_escape.cpp @@ -4,6 +4,7 @@ #include "desktopentry.h" #include "dbus/applicationservice.h" +#include "global.h" #include #include #include @@ -80,3 +81,246 @@ TEST(ApplicationServiceTest, SplitExecArguments_PassPhase2_Spec) << "\nReason: " << tc.reason.toStdString(); } } + +TEST(ApplicationServiceTest, EscapeToObjectPath_UTF8ByteSequence) +{ + struct TestCase + { + QString input; + QString expected; + QString reason; + }; + + const QList testCases = { + {"", "_", "Empty string should return underscore"}, + {"simple", "simple", "Safe ASCII characters should not be escaped"}, + {"test/path", "test/path", "Forward slash is safe and should not be escaped"}, + {"test_name", "test_5fname", "Underscore should be escaped"}, + {"test-name", "test_2dname", "Hyphen should be escaped"}, + {"test.name", "test_2ename", "Dot should be escaped"}, + {"test name", "test_20name", "Space should be escaped"}, + {"test\x01", "test_01", "Control character should use 2-digit hex"}, + {QString::fromUtf8("测试"), "_e6_b5_8b_e8_af_95", "Chinese: UTF-8 bytes E6 B5 8B E8 AF 95"}, + {QString::fromUtf8("日本語"), "_e6_97_a5_e6_9c_ac_e8_aa_9e", "Japanese: UTF-8 bytes"}, + {QString::fromUtf8("café"), "caf_c3_a9", "Latin-1: é is UTF-8 C3 A9"}, + }; + + for (const auto &tc : testCases) { + const auto result = DUtil::escapeToObjectPath(tc.input); + EXPECT_EQ(result, tc.expected) << "Failed: " << tc.reason.toStdString() + << "\nInput: " << tc.input.toStdString() + << "\nExpected: " << tc.expected.toStdString() + << "\nActual: " << result.toStdString(); + } +} + +TEST(ApplicationServiceTest, UnescapeFromObjectPath_UTF8ByteSequence) +{ + struct TestCase + { + QString input; + QString expected; + QString reason; + }; + + const QList testCases = { + {"simple", "simple", "Safe string should not change"}, + {"test_20name", "test name", "Escaped space"}, + {"test_5fname", "test_name", "Escaped underscore"}, + {"test_01", "test\x01", "2-digit hex for control character"}, + {"_e6_b5_8b_e8_af_95", QString::fromUtf8("测试"), "Chinese UTF-8 bytes"}, + {"test_e4_b8_adpath", QString::fromUtf8("test中path"), "Mixed ASCII and UTF-8 bytes"}, + }; + + for (const auto &tc : testCases) { + const auto result = DUtil::unescapeFromObjectPath(tc.input); + EXPECT_EQ(result, tc.expected) << "Failed: " << tc.reason.toStdString() + << "\nInput: " << tc.input.toStdString() + << "\nExpected: " << tc.expected.toStdString() + << "\nActual: " << result.toStdString(); + } +} + +TEST(ApplicationServiceTest, ObjectPath_RoundTrip) +{ + const QList testCases = { + "simple", + "test/path", + "test_name", + "test name", + QString::fromUtf8("测试应用"), + QString::fromUtf8("日本語"), + QString::fromUtf8("한글"), + "test\x01", + QString::fromUtf8("café"), + }; + + for (const auto &tc : testCases) { + const auto escaped = DUtil::escapeToObjectPath(tc); + const auto unescaped = DUtil::unescapeFromObjectPath(escaped); + + EXPECT_EQ(unescaped, tc) << "Round-trip failed for: " << tc.toStdString() + << "\nEscaped: " << escaped.toStdString() + << "\nUnescaped: " << unescaped.toStdString(); + } +} + +TEST(ApplicationServiceTest, ObjectPath_DBusCompliance) +{ + auto isValidDBusPathElement = [](const QString &element) { + if (element.isEmpty()) return false; + for (const auto &ch : element) { + const auto code = ch.unicode(); + bool isValid = (code >= 'a' && code <= 'z') || + (code >= 'A' && code <= 'Z') || + (code >= '0' && code <= '9') || + code == '_'; + if (!isValid) return false; + } + return true; + }; + + const QList testCases = { + "simple", + "test/path", + "test_name", + "test name", + QString::fromUtf8("测试"), + QString::fromUtf8("日本語"), + QString::fromUtf8("café"), + }; + + for (const auto &tc : testCases) { + const auto escaped = DUtil::escapeToObjectPath(tc); + + auto segments = escaped.split('/', Qt::SkipEmptyParts); + for (const auto &seg : segments) { + EXPECT_TRUE(isValidDBusPathElement(seg)) + << "Path element is not D-Bus compliant: " << seg.toStdString() + << "\nOriginal: " << tc.toStdString(); + } + } +} + +TEST(ApplicationServiceTest, EscapeApplicationId_SystemdStandard) +{ + struct TestCase + { + QString input; + QString expected; + QString reason; + }; + + const QList testCases = { + {"", "", "Empty string should remain empty"}, + {"simple", "simple", "Safe ASCII characters should not be escaped"}, + {"test.name", "test.name", "Dot is safe and should not be escaped"}, + {"test_name", "test_name", "Underscore is safe and should not be escaped"}, + {"org/app", "org-app", "Forward slash should be replaced with hyphen"}, + {".hidden", "\\x2ehidden", "Leading dot should be escaped"}, + {"test app", "test\\x20app", "Space should be escaped"}, + {"test-name", "test\\x2dname", "Hyphen should be escaped"}, + {"org.example.App", "org.example.App", "Typical desktop ID should not be changed"}, + {"org/example/App", "org-example-App", "Path-style desktop ID with slashes"}, + {"test!special", "test\\x21special", "Exclamation mark should be escaped"}, + {"/", "-", "Single slash becomes single hyphen"}, + {"//", "--", "Multiple slashes: each becomes hyphen (non-path mode)"}, + {"test\x01", "test\\x01", "Control character should be escaped"}, + {QString::fromUtf8("测试"), "\\xe6\\xb5\\x8b\\xe8\\xaf\\x95", "Chinese: UTF-8 bytes E6 B5 8B E8 AF 95"}, + {QString::fromUtf8("日本語"), "\\xe6\\x97\\xa5\\xe6\\x9c\\xac\\xe8\\xaa\\x9e", "Japanese: UTF-8 bytes"}, + {QString::fromUtf8("café"), "caf\\xc3\\xa9", "Latin-1: é is UTF-8 C3 A9"}, + }; + + for (const auto &tc : testCases) { + const auto result = escapeApplicationId(tc.input); + EXPECT_EQ(result, tc.expected) << "Failed: " << tc.reason.toStdString() + << "\nInput: " << tc.input.toStdString() + << "\nExpected: " << tc.expected.toStdString() + << "\nActual: " << result.toStdString(); + } +} + +TEST(ApplicationServiceTest, EscapeApplicationId_SystemdEscapeCompatible) +{ + // Test that our implementation matches systemd-escape command + struct TestCase + { + QString input; + QString reason; + }; + + const QList testCases = { + {"org/example/App", "Path-style ID"}, + {QString::fromUtf8("测试"), "Chinese characters"}, + {QString::fromUtf8("café"), "Latin-1 with accent"}, + {"test app", "Space in name"}, + {".hidden", "Leading dot"}, + {"test-name", "Hyphen"}, + }; + + for (const auto &tc : testCases) { + const auto escaped = escapeApplicationId(tc.input); + + // Verify the escaped string is valid for systemd + EXPECT_FALSE(escaped.isEmpty() && !tc.input.isEmpty()) + << "Non-empty input should produce non-empty output: " << tc.input.toStdString(); + + EXPECT_FALSE(escaped.startsWith('.')) + << "Escaped ID should not start with dot: " << escaped.toStdString(); + } +} + +TEST(ApplicationServiceTest, UnescapeApplicationId_UTF8ByteSequence) +{ + struct TestCase + { + QString input; + QString expected; + QString reason; + }; + + const QList testCases = { + {"", "", "Empty string should remain empty"}, + {"simple", "simple", "Safe string should not change"}, + {"test\\x20app", "test app", "Escaped space"}, + {"test\\x2dapp", "test-app", "Escaped hyphen"}, + {"org-app", "org-app", "Hyphen should remain"}, + {"\\x2e", ".", "Single escaped dot"}, + {"test\\x", "test\\x", "Incomplete escape sequence should be preserved"}, + {"test\\xZZ", "test\\xZZ", "Invalid hex digits should be preserved"}, + {"test\\", "test\\", "Trailing backslash should be preserved"}, + {"\\xe6\\xb5\\x8b\\xe8\\xaf\\x95", QString::fromUtf8("测试"), "Chinese UTF-8 bytes"}, + {"caf\\xc3\\xa9", QString::fromUtf8("café"), "Latin-1 UTF-8 bytes"}, + }; + + for (const auto &tc : testCases) { + const auto result = unescapeApplicationId(tc.input); + EXPECT_EQ(result, tc.expected) << "Failed: " << tc.reason.toStdString() + << "\nInput: " << tc.input.toStdString() + << "\nExpected: " << tc.expected.toStdString() + << "\nActual: " << result.toStdString(); + } +} + +TEST(ApplicationServiceTest, ApplicationId_RoundTrip) +{ + const QList testCases = { + "simple", + "test.name", + "test_name", + QString::fromUtf8("测试应用"), + QString::fromUtf8("日本語"), + QString::fromUtf8("한글"), + QString::fromUtf8("café"), + "test\x01", + }; + + for (const auto &tc : testCases) { + const auto escaped = escapeApplicationId(tc); + const auto unescaped = unescapeApplicationId(escaped); + + EXPECT_EQ(unescaped, tc) << "Round-trip failed for: " << tc.toStdString() + << "\nEscaped: " << escaped.toStdString() + << "\nUnescaped: " << unescaped.toStdString(); + } +}