diff --git a/src/format/BitwardenReader.cpp b/src/format/BitwardenReader.cpp index 5f729aa776..17f5b5cbdf 100644 --- a/src/format/BitwardenReader.cpp +++ b/src/format/BitwardenReader.cpp @@ -211,6 +211,78 @@ namespace return entry.take(); } + /*! + * Create nested folder hierarchy from a path string. + * For example, "Socials/Forums" creates a "Socials" group with a "Forums" child group. + * Returns the deepest (leaf) group in the hierarchy. + */ + Group* + createNestedFolderHierarchy(const QString& folderPath, Group* rootGroup, QMap& createdGroups) + { + if (folderPath.isEmpty()) { + return rootGroup; + } + + // Check if we've already created this exact path + if (createdGroups.contains(folderPath)) { + return createdGroups.value(folderPath); + } + + // Split the path by forward slashes + QStringList pathParts = folderPath.split('/', Qt::SkipEmptyParts); + if (pathParts.isEmpty()) { + return rootGroup; + } + + Group* currentParent = rootGroup; + QString currentPath; + + // Create each level of the hierarchy + for (int i = 0; i < pathParts.size(); ++i) { + const QString& partName = pathParts[i]; + + // Build the current path (e.g., "Socials", then "Socials/Forums") + if (currentPath.isEmpty()) { + currentPath = partName; + } else { + currentPath += "/" + partName; + } + + // Check if this level already exists + Group* existingGroup = createdGroups.value(currentPath); + if (existingGroup) { + currentParent = existingGroup; + continue; + } + + // Find existing child group with this name + existingGroup = nullptr; + for (Group* child : currentParent->children()) { + if (child->name() == partName) { + existingGroup = child; + break; + } + } + + if (existingGroup) { + // Use existing group + createdGroups.insert(currentPath, existingGroup); + currentParent = existingGroup; + } else { + // Create new group + auto newGroup = new Group(); + newGroup->setUuid(QUuid::createUuid()); + newGroup->setName(partName); + newGroup->setParent(currentParent); + + createdGroups.insert(currentPath, newGroup); + currentParent = newGroup; + } + } + + return currentParent; + } + void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer db) { auto folderField = QString("folders"); @@ -224,15 +296,19 @@ namespace return; } - // Create groups from folders and store a temporary map of id -> uuid + // Create groups from folders and store a temporary map of id -> group QMap folderMap; + QMap createdGroups; // Track created groups by path to avoid duplicates + for (const auto& folder : vault.value(folderField).toArray()) { - auto group = new Group(); - group->setUuid(QUuid::createUuid()); - group->setName(folder.toObject().value("name").toString()); - group->setParent(db->rootGroup()); + const QString folderName = folder.toObject().value("name").toString(); + const QString folderId = folder.toObject().value("id").toString(); + + // Create the nested folder hierarchy + Group* targetGroup = createNestedFolderHierarchy(folderName, db->rootGroup(), createdGroups); - folderMap.insert(folder.toObject().value("id").toString(), group); + // Map the folder ID to the target group + folderMap.insert(folderId, targetGroup); } QString folderId; diff --git a/tests/TestImports.cpp b/tests/TestImports.cpp index 17ec2bef53..ce26d628a5 100644 --- a/tests/TestImports.cpp +++ b/tests/TestImports.cpp @@ -317,6 +317,56 @@ void TestImports::testBitwardenPasskey() QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw")); } +void TestImports::testBitwardenNestedFolders() +{ + auto bitwardenPath = + QStringLiteral("%1/%2").arg(KEEPASSX_TEST_DATA_DIR, QStringLiteral("/bitwarden_nested_export.json")); + + BitwardenReader reader; + auto db = reader.convert(bitwardenPath); + QVERIFY2(!reader.hasError(), qPrintable(reader.errorString())); + QVERIFY(db); + + // Test nested folder structure: "Socials/Forums" should create Socials -> Forums hierarchy + auto entry = db->rootGroup()->findEntryByPath("/Socials/Forums/Reddit Account"); + QVERIFY(entry); + QCOMPARE(entry->title(), QStringLiteral("Reddit Account")); + QCOMPARE(entry->username(), QStringLiteral("myuser")); + + // Test deeper nesting: "Work/Projects/Client A" + entry = db->rootGroup()->findEntryByPath("/Work/Projects/Client A/Client Portal"); + QVERIFY(entry); + QCOMPARE(entry->title(), QStringLiteral("Client Portal")); + QCOMPARE(entry->username(), QStringLiteral("clientuser")); + + // Test simple folder (no nesting): "Personal" + entry = db->rootGroup()->findEntryByPath("/Personal/Personal Email"); + QVERIFY(entry); + QCOMPARE(entry->title(), QStringLiteral("Personal Email")); + QCOMPARE(entry->username(), QStringLiteral("personal@email.com")); + + // Verify the folder hierarchy exists + auto socialsGroup = db->rootGroup()->findGroupByPath("/Socials"); + QVERIFY(socialsGroup); + QCOMPARE(socialsGroup->name(), QStringLiteral("Socials")); + + auto forumsGroup = socialsGroup->findGroupByPath("Forums"); + QVERIFY(forumsGroup); + QCOMPARE(forumsGroup->name(), QStringLiteral("Forums")); + + auto workGroup = db->rootGroup()->findGroupByPath("/Work"); + QVERIFY(workGroup); + QCOMPARE(workGroup->name(), QStringLiteral("Work")); + + auto projectsGroup = workGroup->findGroupByPath("Projects"); + QVERIFY(projectsGroup); + QCOMPARE(projectsGroup->name(), QStringLiteral("Projects")); + + auto clientAGroup = projectsGroup->findGroupByPath("Client A"); + QVERIFY(clientAGroup); + QCOMPARE(clientAGroup->name(), QStringLiteral("Client A")); +} + void TestImports::testProtonPass() { auto protonPassPath = diff --git a/tests/TestImports.h b/tests/TestImports.h index 728fa63775..6d21e4c34f 100644 --- a/tests/TestImports.h +++ b/tests/TestImports.h @@ -31,6 +31,7 @@ private slots: void testBitwarden(); void testBitwardenEncrypted(); void testBitwardenPasskey(); + void testBitwardenNestedFolders(); void testProtonPass(); }; diff --git a/tests/data/bitwarden_nested_export.json b/tests/data/bitwarden_nested_export.json new file mode 100644 index 0000000000..71ce1756f0 --- /dev/null +++ b/tests/data/bitwarden_nested_export.json @@ -0,0 +1,72 @@ +{ + "folders": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "Socials/Forums" + }, + { + "id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "name": "Work/Projects/Client A" + }, + { + "id": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz", + "name": "Personal" + } + ], + "items": [ + { + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa", + "organizationId": null, + "folderId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "type": 1, + "name": "Reddit Account", + "notes": "My reddit login", + "favorite": false, + "login": { + "username": "myuser", + "password": "mypass", + "uris": [ + { + "uri": "https://reddit.com" + } + ] + } + }, + { + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "organizationId": null, + "folderId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "type": 1, + "name": "Client Portal", + "notes": "Client A portal login", + "favorite": false, + "login": { + "username": "clientuser", + "password": "clientpass", + "uris": [ + { + "uri": "https://clienta.com" + } + ] + } + }, + { + "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "organizationId": null, + "folderId": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz", + "type": 1, + "name": "Personal Email", + "notes": "My personal email", + "favorite": false, + "login": { + "username": "personal@email.com", + "password": "personalpass", + "uris": [ + { + "uri": "https://mail.provider.com" + } + ] + } + } + ] +} \ No newline at end of file