Files
orbithub/src/mremoteng_importer.cpp
T
ksmithandClaude Sonnet 5 dbcc20d155 Add File -> Import from mRemoteNG...
Parses mRemoteNG's confCons.xml export format directly -- schema verified
against mRemoteNG's own XmlConnectionsDeserializer.cs source and a real
exported sample, not guessed at. Maps nested <Node Type="Container">
folders and <Node Type="Connection"> entries onto OrbitHub profiles:
RDP stays RDP, SSH1/SSH2 collapse to OrbitHub's single SSH protocol,
anything else (VNC, Telnet, HTTP, PowerShell, ...) is skipped and listed
in the import summary rather than silently dropped.

Passwords are never read, not even for the common case where they're
technically readable without a master password (mRemoteNG only encrypts
the Password attribute itself, everything else -- Hostname, Username,
Domain, Protocol -- is plaintext). A FullFileEncryption="true" export
encrypts the whole node tree instead and genuinely can't be read without
the user's master password; that case is detected and refused with a
clear message rather than failing confusingly.

The parser (src/mremoteng_importer.h/.cpp) is a pure function decoupled
from any file/UI I/O, matching this session's established pattern of
keeping business logic separately testable from the Qt Widgets shell that
calls it (ProfilesWindow::importFromMRemoteNG() is the thin wrapper:
QFileDialog, call the parser, write results via ProfileRepository, show a
summary). 11 test cases against realistic sample XML.

Hit a real moc gotcha along the way: a literal "//" inside a raw string
literal (the xmlns URL) makes moc's lexer think a line comment started
there, silently desyncing its parse so it never finds the QObject-derived
test class at all (no error, just a missing vtable at link time). Fixed
by moving the XML fixtures into a plain non-QObject header moc never
scans, split across two adjacent literals as a second safeguard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:08:02 -06:00

153 lines
6.1 KiB
C++

#include "mremoteng_importer.h"
#include <QStringList>
#include <QXmlStreamReader>
namespace {
QString joinFolderPath(const QStringList& parts)
{
QStringList trimmed;
for (const QString& part : parts) {
const QString t = part.trimmed();
if (!t.isEmpty()) {
trimmed.push_back(t);
}
}
return trimmed.join(QStringLiteral("/"));
}
// Maps mRemoteNG's Protocol attribute (an enum: RDP, VNC, SSH1, SSH2,
// Telnet, Rlogin, RAW, HTTP, HTTPS, PowerShell, IntApp, Winbox) onto what
// OrbitHub actually supports. *supported is set to false for anything
// OrbitHub can't yet connect to (VNC included -- see issue #3).
QString mappedProtocol(const QString& mRemoteNGProtocol, bool* supported)
{
const QString p = mRemoteNGProtocol.trimmed();
if (p.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
*supported = true;
return QStringLiteral("RDP");
}
if (p.compare(QStringLiteral("SSH1"), Qt::CaseInsensitive) == 0
|| p.compare(QStringLiteral("SSH2"), Qt::CaseInsensitive) == 0) {
*supported = true;
return QStringLiteral("SSH");
}
*supported = false;
return p;
}
int defaultPortFor(const QString& orbitHubProtocol)
{
return orbitHubProtocol == QStringLiteral("RDP") ? 3389 : 22;
}
}
MRemoteNGImportResult parseMRemoteNGConnections(const QByteArray& xmlData)
{
MRemoteNGImportResult result;
QXmlStreamReader reader(xmlData);
bool foundRoot = false;
while (!reader.atEnd() && !foundRoot) {
if (reader.readNext() == QXmlStreamReader::StartElement) {
if (reader.name().compare(QStringLiteral("Connections"), Qt::CaseInsensitive) != 0) {
result.errorMessage = QStringLiteral(
"This doesn't look like an mRemoteNG connections file (expected a "
"<Connections> root element).");
return result;
}
foundRoot = true;
const auto attrs = reader.attributes();
if (attrs.value(QStringLiteral("FullFileEncryption"))
.compare(QStringLiteral("true"), Qt::CaseInsensitive)
== 0) {
result.errorMessage = QStringLiteral(
"This file has \"full file encryption\" enabled in mRemoteNG, which "
"encrypts the entire connection list with your master password. "
"OrbitHub has no way to read that. In mRemoteNG, re-export with full "
"file encryption turned off (only the saved passwords need stay "
"encrypted -- OrbitHub never imports those anyway).");
return result;
}
}
}
if (!foundRoot) {
result.errorMessage = QStringLiteral("Not a valid XML file, or the file is empty.");
return result;
}
QStringList folderStack;
std::vector<bool> openNodeIsContainer;
while (!reader.atEnd()) {
const QXmlStreamReader::TokenType token = reader.readNext();
if (token == QXmlStreamReader::StartElement
&& reader.name().compare(QStringLiteral("Node"), Qt::CaseInsensitive) == 0) {
const auto attrs = reader.attributes();
const QString type = attrs.value(QStringLiteral("Type")).toString();
const bool isContainer =
type.compare(QStringLiteral("Container"), Qt::CaseInsensitive) == 0;
const QString name = attrs.value(QStringLiteral("Name")).toString().trimmed();
if (isContainer) {
folderStack.push_back(name);
const QString path = joinFolderPath(folderStack);
if (!path.isEmpty()) {
result.folders.push_back(path);
}
} else {
bool supported = false;
const QString protocolLabel = attrs.value(QStringLiteral("Protocol")).toString();
const QString mappedProto = mappedProtocol(protocolLabel, &supported);
if (!supported) {
result.skippedUnsupportedProtocol.push_back(QStringLiteral("%1 (%2)").arg(
name.isEmpty() ? QStringLiteral("(unnamed)") : name,
protocolLabel.isEmpty() ? QStringLiteral("unknown") : protocolLabel));
} else {
MRemoteNGImportedProfile imported;
imported.profile.name = name;
imported.profile.host = attrs.value(QStringLiteral("Hostname")).toString().trimmed();
const QString portText = attrs.value(QStringLiteral("Port")).toString();
imported.profile.port =
portText.isEmpty() ? defaultPortFor(mappedProto) : portText.toInt();
if (imported.profile.port <= 0) {
imported.profile.port = defaultPortFor(mappedProto);
}
imported.profile.username = attrs.value(QStringLiteral("Username")).toString();
imported.profile.protocol = mappedProto;
imported.profile.authMode = QStringLiteral("Password");
if (mappedProto == QStringLiteral("RDP")) {
imported.profile.domain = attrs.value(QStringLiteral("Domain")).toString();
}
imported.folderPath = joinFolderPath(folderStack);
result.profiles.push_back(imported);
}
}
openNodeIsContainer.push_back(isContainer);
} else if (token == QXmlStreamReader::EndElement
&& reader.name().compare(QStringLiteral("Node"), Qt::CaseInsensitive) == 0) {
if (!openNodeIsContainer.empty()) {
if (openNodeIsContainer.back()) {
folderStack.removeLast();
}
openNodeIsContainer.pop_back();
}
}
}
if (reader.hasError()) {
result.errorMessage = QStringLiteral("Failed to parse XML: %1").arg(reader.errorString());
result.folders.clear();
result.profiles.clear();
result.skippedUnsupportedProtocol.clear();
}
return result;
}