Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 82 additions & 6 deletions src/format/BitwardenReader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<QString, Group*>& 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<Database> db)
{
auto folderField = QString("folders");
Expand All @@ -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<QString, Group*> folderMap;
QMap<QString, Group*> 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;
Expand Down
50 changes: 50 additions & 0 deletions tests/TestImports.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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("[email protected]"));

// 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 =
Expand Down
1 change: 1 addition & 0 deletions tests/TestImports.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ private slots:
void testBitwarden();
void testBitwardenEncrypted();
void testBitwardenPasskey();
void testBitwardenNestedFolders();
void testProtonPass();
};

Expand Down
72 changes: 72 additions & 0 deletions tests/data/bitwarden_nested_export.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"password": "personalpass",
"uris": [
{
"uri": "https://mail.provider.com"
}
]
}
}
]
}