From a1cddad37c28cd4de7b01d4147208fe90e4671e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Sun, 8 Mar 2026 17:18:22 +0200 Subject: [PATCH] Add support for nested folders with Bitwarden import (#13081) Co-authored-by: varjolintu --- src/format/BitwardenReader.cpp | 52 ++++- tests/TestImports.cpp | 102 +++++++++- tests/TestImports.h | 3 +- tests/data/bitwarden_nested_export.json | 249 ++++++++++++++++++++++++ 4 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 tests/data/bitwarden_nested_export.json diff --git a/src/format/BitwardenReader.cpp b/src/format/BitwardenReader.cpp index 43b2a34f3..37e361929 100644 --- a/src/format/BitwardenReader.cpp +++ b/src/format/BitwardenReader.cpp @@ -261,6 +261,48 @@ namespace return entry.take(); } + Group* createGroup(Group* rootGroup, const QString& folderName) + { + Group* currentParentGroup = rootGroup; + Group* result = nullptr; + const auto groups = folderName.split("/", Qt::SkipEmptyParts); + + // Returns the group name based on depth + const auto getGroupName = [&](const int depth) { + QString groupName; + for (int i = 0; i < depth + 1; ++i) { + groupName.append((i == 0 ? "" : "/") + groups[i]); + } + return groupName; + }; + + // Create new group(s) always when the path is not found + for (int i = 0; i < groups.length(); ++i) { + const auto groupName = getGroupName(i); + const auto tempGroup = rootGroup->findGroupByPath(groupName); + + if (!tempGroup) { + const auto newGroup = new Group(); + newGroup->setName(groups[i]); + newGroup->setUuid(QUuid::createUuid()); + newGroup->setParent(currentParentGroup); + currentParentGroup = newGroup; + + if (groupName == folderName) { + result = newGroup; + } + continue; + } + + if (groupName == folderName) { + result = tempGroup; + } + currentParentGroup = tempGroup; + } + + return result; + } + void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer db) { auto folderField = QString("folders"); @@ -277,12 +319,12 @@ namespace // Create groups from folders and store a temporary map of id -> uuid QMap folderMap; 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 auto folderId = folder.toObject().value("id").toString(); + const auto folderName = folder.toObject().value("name").toString(); - folderMap.insert(folder.toObject().value("id").toString(), group); + if (const auto group = createGroup(db->rootGroup(), folderName)) { + folderMap.insert(folderId, group); + } } QString folderId; diff --git a/tests/TestImports.cpp b/tests/TestImports.cpp index d17beb389..e2db29b6e 100644 --- a/tests/TestImports.cpp +++ b/tests/TestImports.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -327,6 +327,106 @@ 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); + + /* The group tree should be: + / + - Example + - Test Authentication + /SecondTest + - GMail entry + /Test + - Gmail test 2 + /Subfolder + - Webauthn.io test 2 + /Subfolder + - Test Account + /SubFolder + - WebAuthn.io test + /AnotherSubFolder + - Another test account + - Webauthn.io test 3 + */ + + // Verify groups + auto secondTestGroup = db->rootGroup()->findGroupByPath("/SecondTest"); + QVERIFY(secondTestGroup); + auto testGroup = db->rootGroup()->findGroupByPath("/Test"); + QVERIFY(testGroup); + auto testSubfolderLowercaseGroup = db->rootGroup()->findGroupByPath("/Test/Subfolder"); + QVERIFY(testSubfolderLowercaseGroup); + auto testSubFolderGroup = db->rootGroup()->findGroupByPath("/Test/SubFolder"); + QVERIFY(testSubFolderGroup); + auto longGroup = db->rootGroup()->findGroupByPath("/Test/SubFolder/AnotherSubFolder"); + QVERIFY(longGroup); + + // Verify entries and the groups they belong to + + // GMail entry + auto entry = db->rootGroup()->findEntryByPath("/SecondTest/GMail entry"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("example@gmail.com")); + QCOMPARE(entry->group(), secondTestGroup); + + // Test Authentication + entry = db->rootGroup()->findEntryByPath("/Test Authentication"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("test@testauthentication.com")); + QCOMPARE(entry->group(), db->rootGroup()); + + // Gmail test 2 + entry = db->rootGroup()->findEntryByPath("/Test/Gmail test 2"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("example2@gmail.com")); + QCOMPARE(entry->group(), testGroup); + + // Example + entry = db->rootGroup()->findEntryByPath("/Example"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("user@example.com")); + QCOMPARE(entry->group(), db->rootGroup()); + + // WebAuthn.io test + entry = db->rootGroup()->findEntryByPath("/Test/SubFolder/WebAuthn.io test"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("testUser")); + QCOMPARE(entry->group(), testSubFolderGroup); + + // Webauthn.io test 2 + entry = db->rootGroup()->findEntryByPath("/Test/Subfolder/Webauthn.io test 2"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("testUser2")); + QCOMPARE(entry->group(), testSubfolderLowercaseGroup); + + // Webauthn.io test 3 + entry = db->rootGroup()->findEntryByPath("/Test/SubFolder/AnotherSubFolder/Webauthn.io test 3"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("testUser3")); + QCOMPARE(entry->group(), longGroup); + + // Test Account + // There are two groups with an identical name. The group for this entry should not be the same group with the + // Webauthn.io test 2, but we cannot distinguish these. + entry = db->rootGroup()->findEntryByPath("/Test/Subfolder/Test Account"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("test-account")); + QCOMPARE(entry->group(), testSubfolderLowercaseGroup); + + // Another test account + entry = db->rootGroup()->findEntryByPath("/Test/SubFolder/AnotherSubFolder/Another test account"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("anotherUser")); + QCOMPARE(entry->group(), longGroup); +} + void TestImports::testProtonPass() { auto protonPassPath = diff --git a/tests/TestImports.h b/tests/TestImports.h index 728fa6377..f7d37e515 100644 --- a/tests/TestImports.h +++ b/tests/TestImports.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -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 000000000..315d9cf75 --- /dev/null +++ b/tests/data/bitwarden_nested_export.json @@ -0,0 +1,249 @@ +{ + "encrypted": false, + "folders": [ + { + "id": "53f3c6e7-a167-47e2-91bb-b3f900a377a3", + "name": "SecondTest" + }, + { + "id": "14f22922-b8ed-4e9c-814b-b3f900a36a92", + "name": "Test" + }, + { + "id": "da442766-39b4-4fb1-a2f3-b3f900a3a36d", + "name": "Test/Subfolder" + }, + { + "id": "0504d89b-00aa-41a5-9355-b3f900a3b43f", + "name": "Test/Subfolder" + }, + { + "id": "c96cf0e9-fd44-4a7e-9619-b3f900a58e59", + "name": "Test/SubFolder" + }, + { + "id": "5d262faf-329d-4197-9e8d-b3f900a3934c", + "name": "Test/SubFolder/AnotherSubFolder" + } + ], + "items": [ + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:57:23.033Z", + "creationDate": "2026-02-17T16:55:23.210Z", + "id": "6b154a7d-4b62-44aa-ae0d-b3f40116e266", + "folderId": "53f3c6e7-a167-47e2-91bb-b3f900a377a3", + "type": 1, + "reprompt": 0, + "name": "GMail entry", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://accounts.google.com" + } + ], + "fido2Credentials": [], + "username": "example@gmail.com", + "password": "examplePassword", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-20T17:45:32.670Z", + "creationDate": "2026-02-20T17:45:32.670Z", + "id": "6ccafb74-ecab-482f-88b2-b3f70124a91b", + "type": 1, + "reprompt": 0, + "name": "Test Authentication", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://testauthentication.com/login" + } + ], + "fido2Credentials": [], + "username": "test@testauthentication.com", + "password": "testPassword", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:57:15.540Z", + "creationDate": "2026-02-06T19:22:48.860Z", + "id": "a9f00893-346e-4be6-ac4e-b3e9013f6065", + "folderId": "14f22922-b8ed-4e9c-814b-b3f900a36a92", + "type": 1, + "reprompt": 0, + "name": "Gmail test 2", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://accounts.google.com" + } + ], + "fido2Credentials": [], + "username": "example2@gmail.com", + "password": "examplePassword2", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-09T13:15:16.370Z", + "creationDate": "2026-02-09T13:15:16.113Z", + "id": "b375fe89-756a-41be-bcec-b3ec00da6d55", + "type": 1, + "reprompt": 0, + "name": "Example", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://www.example.com/" + } + ], + "username": "user@example.com", + "password": "examplePassword", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T10:03:05.596Z", + "creationDate": "2026-02-09T13:15:45.020Z", + "id": "b42284e2-a103-4dd0-982c-b3ec00da8f36", + "folderId": "c96cf0e9-fd44-4a7e-9619-b3f900a58e59", + "type": 1, + "reprompt": 0, + "name": "WebAuthn.io test", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://webauthn.io/" + } + ], + "username": "testUser", + "password": "testPassword", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:56:58.923Z", + "creationDate": "2024-10-23T16:38:08.606Z", + "id": "a8e579f0-98c2-4ac9-a126-b212011225f8", + "folderId": "da442766-39b4-4fb1-a2f3-b3f900a3a36d", + "type": 1, + "reprompt": 0, + "name": "Webauthn.io test 2", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://webauthn.io/" + } + ], + "username": "testUser2", + "password": "testPassword2", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:56:46.026Z", + "creationDate": "2025-10-29T06:13:55.333Z", + "id": "a88363cf-9fea-43d8-a3dd-b3850066b36a", + "folderId": "5d262faf-329d-4197-9e8d-b3f900a3934c", + "type": 1, + "reprompt": 0, + "name": "Webauthn.io test 3", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://webauthn.io/" + } + ], + "username": "testUser3", + "password": "testPassword3", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2025-09-12T16:25:15.850Z", + "creationDate": "2025-09-12T16:25:15.850Z", + "id": "d2946603-1bfc-4eee-8805-b356010e9c65", + "folderId": "0504d89b-00aa-41a5-9355-b3f900a3b43f", + "type": 1, + "reprompt": 0, + "name": "Test Account", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://testsite.com/" + } + ], + "fido2Credentials": [], + "username": "test-account", + "password": "test", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:56:52.673Z", + "creationDate": "2024-10-23T19:17:45.433Z", + "id": "4e3b570e-3ead-4557-b5be-b212013dfcd0", + "folderId": "5d262faf-329d-4197-9e8d-b3f900a3934c", + "type": 1, + "reprompt": 0, + "name": "Another test account", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://anothertestsite.org/" + } + ], + "username": "anotherUser", + "password": "anotherPassword", + "totp": null + }, + "collectionIds": null + } + ] +} \ No newline at end of file