From ab87133f0282963f399b7eaea3b8087271f60558 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 15:46:37 +0700 Subject: [PATCH 01/12] feat(core): import connections from other apps on every platform --- .github/workflows/core.yml | 5 +- .oss-scanner/threat_model.md | 2 +- Cargo.lock | 91 +- Cargo.toml | 15 + core/ARCHITECTURE.md | 4 + core/crates/foreign-import/Cargo.toml | 44 + core/crates/foreign-import/src/app.rs | 215 ++ .../src/apps/beekeeper/encryptor.rs | 99 + .../src/apps/beekeeper/locations.rs | 125 + .../foreign-import/src/apps/beekeeper/mod.rs | 401 +++ .../src/apps/beekeeper/saved_queries.rs | 208 ++ .../src/apps/beekeeper/sqlite.rs | 135 + .../src/apps/datagrip/consoles.rs | 168 ++ .../src/apps/datagrip/data_sources.rs | 245 ++ .../src/apps/datagrip/locations.rs | 187 ++ .../foreign-import/src/apps/datagrip/mod.rs | 517 ++++ .../src/apps/dbeaver/credentials.rs | 165 ++ .../src/apps/dbeaver/locations.rs | 300 ++ .../foreign-import/src/apps/dbeaver/mod.rs | 515 ++++ .../src/apps/dbeaver/scripts.rs | 105 + core/crates/foreign-import/src/apps/jdbc.rs | 610 ++++ .../src/apps/jetbrains/credential_store.rs | 495 ++++ .../src/apps/jetbrains/inner_stream.rs | 118 + .../foreign-import/src/apps/jetbrains/kdbx.rs | 410 +++ .../foreign-import/src/apps/jetbrains/mod.rs | 13 + .../src/apps/jetbrains/path_macros.rs | 19 + .../src/apps/jetbrains/test_kdbx.rs | 170 ++ .../foreign-import/src/apps/jetbrains/xml.rs | 53 + core/crates/foreign-import/src/apps/mod.rs | 42 + .../foreign-import/src/apps/navicat/cipher.rs | 161 ++ .../foreign-import/src/apps/navicat/mod.rs | 237 ++ .../foreign-import/src/apps/sequel_ace/mod.rs | 432 +++ .../src/apps/sequel_ace/query_favorites.rs | 59 + .../src/apps/tableplus/connection.rs | 516 ++++ .../src/apps/tableplus/favorites.rs | 204 ++ .../src/apps/tableplus/form_values.rs | 147 + .../src/apps/tableplus/linux.rs | 158 + .../src/apps/tableplus/locations.rs | 344 +++ .../foreign-import/src/apps/tableplus/mod.rs | 377 +++ core/crates/foreign-import/src/collect.rs | 215 ++ core/crates/foreign-import/src/dirs.rs | 62 + core/crates/foreign-import/src/engine.rs | 421 +++ core/crates/foreign-import/src/error.rs | 51 + core/crates/foreign-import/src/files.rs | 70 + core/crates/foreign-import/src/host.rs | 36 + core/crates/foreign-import/src/lib.rs | 24 + core/crates/foreign-import/src/record.rs | 123 + .../foreign-import/src/review/analyze.rs | 259 ++ .../crates/foreign-import/src/review/apply.rs | 159 ++ .../foreign-import/src/review/mapping.rs | 457 +++ .../foreign-import/src/review/match_key.rs | 61 + core/crates/foreign-import/src/review/mod.rs | 120 + .../crates/foreign-import/src/review/notes.rs | 81 + core/crates/foreign-import/src/review/plan.rs | 202 ++ .../foreign-import/src/review/selection.rs | 103 + .../foreign-import/src/sql_file_tree.rs | 218 ++ core/crates/foreign-import/tests/beekeeper.rs | 583 ++++ core/crates/foreign-import/tests/datagrip.rs | 569 ++++ core/crates/foreign-import/tests/dbeaver.rs | 638 +++++ .../tests/fixtures/beekeeper/.key | 1 + .../tests/fixtures/beekeeper/connections.sql | 15 + .../fixtures/beekeeper/more_connections.sql | 26 + .../tests/fixtures/beekeeper/queries.sql | 11 + .../beekeeper/queries_without_folders.sql | 2 + .../tests/fixtures/datagrip/.gitignore | 2 + .../JetBrains/DataGrip2025.1/c.kdbx | Bin 0 -> 939 bytes .../JetBrains/DataGrip2025.1/c.pwd | 3 + .../options/dataSources.local.xml | 41 + .../DataGrip2025.1/options/dataSources.xml | 62 + .../DataGrip2025.1/options/sshConfigs.xml | 9 + .../dg_sample_legacy.sql | 1 + .../dg_sample_report.sql | 1 + .../DataGrip2025.3/options/dataSources.xml | 9 + .../11111111-1111-4111-8111-111111111111.sql | 1 + .../.hidden_console.sql | 1 + .../Console_7.sql | 1 + .../console.sql | 1 + .../console_1.sql | 1 + .../console_3.sql | 1 + .../dg_sample_report.sql | 1 + .../notes.txt | 1 + .../reports/dg_sample_monthly.sql | 1 + .../script.js | 1 + .../dg_sample_orphan.sql | 1 + .../console.js | 1 + .../dg_unmapped_query.sql | 1 + .../DataGrip2026.2/options/dataSources.xml | 14 + .../DataGrip2026.2/scratches/scratch.sql | 1 + .../consoles/escape/dg_sample_outside.sql | 1 + .../DataGrip2026.2/options/dataSources.xml | 9 + .../.config/JetBrains/DataGrip2023.1/c.kdbx | Bin 0 -> 571 bytes .../DataGrip2023.1/options/dataSources.xml | 9 + .../.config/JetBrains/DataGrip2023.1/pdb.pwd | Bin 0 -> 100 bytes .../Roaming/JetBrains/DataGrip2025.2/c.kdbx | Bin 0 -> 715 bytes .../Roaming/JetBrains/DataGrip2025.2/c.pwd | 3 + .../DataGrip2025.2/options/dataSources.xml | 9 + .../DataGrip2025.1/options/dataSources.xml | 13 + .../DataGrip2025.1/options/recentProjects.xml | 16 + .../projects/default/.idea/dataSources.xml | 9 + .../options/dataSources.xml | 9 + .../DataGrip2025.2/options/dataSources.xml | 13 + .../shop/.idea/dataSources.local.xml | 8 + .../shop/.idea/dataSources.xml | 9 + .../DataGrip2025.2/options/dataSources.xml | 9 + .../console.sql | 1 + .../dg_sample_q.sql | 1 + .../dg_sample_q_2.sql | 1 + .../options/QueryFileSettings.xml | 5 + .../DataGrip2026.2/options/dataSources.xml | 9 + .../General/.dbeaver/data-sources.json | 13 + .../General/.dbeaver/data-sources.json | 13 + .../General/.dbeaver/data-sources.json | 13 + .../General/.dbeaver/data-sources.json | 13 + .../.dbeaver/credentials-config-2.json | Bin 0 -> 112 bytes .../.dbeaver/credentials-config-2.plain.json | 8 + .../General/.dbeaver/credentials-config.json | Bin 0 -> 224 bytes .../.dbeaver/credentials-config.plain.json | 14 + .../General/.dbeaver/data-sources-2.json | 14 + .../General/.dbeaver/data-sources-broken.json | 1 + .../General/.dbeaver/data-sources.json | 19 + .../General/.dbeaver/data-sources.json | 3 + .../General/.dbeaver/data-sources.json | 1 + .../General/.dbeaver/data-sources.json | 3 + .../General/.dbeaver/credentials-config.json | Bin 0 -> 416 bytes .../.dbeaver/credentials-config.plain.json | 28 + .../General/.dbeaver/data-sources.json | 507 ++++ .../General/.dbeaver/project-metadata.json | 1 + .../workspace6/General/Scripts/Blank.sql | 1 + .../workspace6/General/Scripts/Elsewhere.sql | 1 + .../workspace6/General/Scripts/Empty.sql | 0 .../General/Scripts/Reports/Daily users.sql | 1 + .../workspace6/General/Scripts/Script-3.sql | 1 + .../workspace6/General/Scripts/Script.sql | 1 + .../workspace6/General/Scripts/Unbound.sql | 1 + .../workspace6/General/Scripts/notes.txt | 1 + .../General/.dbeaver/credentials-config.json | 1 + .../.dbeaver/credentials-config.plain.json | 8 + .../General/.dbeaver/data-sources.json | 13 + .../tests/fixtures/navicat/connections.ncx | 13 + .../tests/fixtures/navicat/doctype.ncx | 7 + .../tests/fixtures/navicat/empty.ncx | 3 + .../tests/fixtures/navicat/malformed.ncx | 1 + .../Sequel Ace/Data/Favorites.plist | 396 +++ .../Sequel Ace/Data/Favorites.plist | 4 + .../Sequel Ace/Data/Favorites.plist | 15 + .../Sequel Ace/Data/Favorites.plist | 324 +++ .../com.sequel-ace.sequel-ace.plist | Bin 0 -> 247 bytes .../Sequel Ace/Data/Favorites.plist | 11 + .../.tableplus/queries/Reports/monthly.sql | 1 + .../queries/Reports/monthly.sql.tag | 1 + .../linux/.tableplus/queries/daily.sql | 1 + .../settings/connection_groups.json | 17 + .../.tableplus/settings/connections.json | 52 + .../linux/.tableplus/settings/settings.json | 4 + .../fixtures/tableplus/linux/Shared/team.sql | 1 + .../Data/Connections.plist | 5 + .../Data/Connections.plist | 18 + .../Data/Connections.plist | 28 + .../Data/Favorite/setapp.sql | 1 + .../com.tinyapp.TablePlus-setapp.plist | Bin 0 -> 239 bytes .../macos-setapp/tp_shared/shared.sql | 1 + .../macos-setapp/tp_shared/shared.sql.tag | 1 + .../macos-shared/Custom/Favorite/custom.sql | 1 + .../tableplus/macos-shared/Custom/ignored.sql | 1 + .../Data/Connections.plist | 28 + .../Data/Favorite/stale.sql | 1 + .../Preferences/com.tinyapp.TablePlus.plist | 14 + .../macos-shared/Moved/ConnectionGroups.plist | 12 + .../macos-shared/Moved/Connections.plist | 28 + .../tableplus/macos/.ssh/id_tableplus_fixture | 1 + .../Data/ConnectionGroups.plist | 26 + .../Data/Connections.plist | 2531 +++++++++++++++++ .../Data/Favorite/.hidden.sql | 1 + .../Data/Favorite/Folder A/Sub/deep.sql | 1 + .../Data/Favorite/Folder A/nested.sql | 2 + .../Data/Favorite/Folder A/nested.sql.tag | 1 + .../Data/Favorite/Folder A/notes.txt | 1 + .../Data/Favorite/Folder A/two.dots.sql | 1 + .../Data/Favorite/Untitled.sql | 0 .../Data/Favorite/orphan.sql.tag | 1 + .../Data/Favorite/tp_root_kw.sql | 1 + .../Data/Favorite/tp_root_kw.sql.tag | 1 + .../Data/Favorite/tp_top.sql | 1 + .../Data/ConnectionGroups.plist | 46 + .../Data/Connections.plist | 130 + .../Data/Favorite/report.sql | 1 + .../Data/Favorite/report.sql.tag | 1 + .../windows/Queries/Favorite/moved.sql | 1 + .../tableplus/windows/Team Queries/team.sql | 1 + .../windows/Team/ConnectionGroups.plist | 16 + .../tableplus/windows/Team/Connections.plist | 32 + core/crates/foreign-import/tests/navicat.rs | 395 +++ core/crates/foreign-import/tests/review.rs | 1924 +++++++++++++ .../crates/foreign-import/tests/sequel_ace.rs | 621 ++++ core/crates/foreign-import/tests/tableplus.rs | 1049 +++++++ .../storage/src/connections/additions.rs | 259 ++ .../storage/src/connections/library/mod.rs | 2 +- core/crates/storage/src/connections/mod.rs | 5 +- core/crates/storage/src/lib.rs | 2 +- core/crates/storage/src/saved_queries/mod.rs | 69 + .../0007-importers-from-other-apps.md | 34 + core/docs/decisions/README.md | 1 + linux/ARCHITECTURE.md | 2 +- linux/build-aux/debian/copyright | 248 ++ linux/build-aux/rpm/tablepro.spec | 1 + linux/crates/app/Cargo.toml | 1 + .../crates/app/src/services/foreign_import.rs | 79 + linux/crates/app/src/services/mod.rs | 1 + .../src/services/saved_connection_editor.rs | 50 + linux/crates/app/src/ui/connect_dialog/mod.rs | 1 + .../crates/app/src/ui/import_from_app/imp.rs | 67 + .../crates/app/src/ui/import_from_app/mod.rs | 669 +++++ .../app/src/ui/import_from_app/tests.rs | 267 ++ .../app/src/ui/import_from_app/wording.rs | 257 ++ linux/crates/app/src/ui/mod.rs | 1 + .../src/ui/session_window/import_from_app.rs | 116 + linux/crates/app/src/ui/session_window/mod.rs | 2 + .../ui/session_window/session_window_input.rs | 1 + .../src/ui/session_window/window_actions.rs | 3 +- linux/crates/platform/Cargo.toml | 1 + linux/crates/platform/src/lib.rs | 2 +- linux/crates/platform/src/paths.rs | 26 + linux/data/licenses/THIRD-PARTY-NOTICE.md | 268 ++ linux/data/licenses/third-party.json | 73 + linux/data/resources/gtk/menus.ui | 4 + linux/data/resources/resources.gresource.xml | 1 + .../resources/ui/import-from-app-dialog.ui | 184 ++ linux/po/POTFILES.in | 7 + 228 files changed, 24059 insertions(+), 9 deletions(-) create mode 100644 core/crates/foreign-import/Cargo.toml create mode 100644 core/crates/foreign-import/src/app.rs create mode 100644 core/crates/foreign-import/src/apps/beekeeper/encryptor.rs create mode 100644 core/crates/foreign-import/src/apps/beekeeper/locations.rs create mode 100644 core/crates/foreign-import/src/apps/beekeeper/mod.rs create mode 100644 core/crates/foreign-import/src/apps/beekeeper/saved_queries.rs create mode 100644 core/crates/foreign-import/src/apps/beekeeper/sqlite.rs create mode 100644 core/crates/foreign-import/src/apps/datagrip/consoles.rs create mode 100644 core/crates/foreign-import/src/apps/datagrip/data_sources.rs create mode 100644 core/crates/foreign-import/src/apps/datagrip/locations.rs create mode 100644 core/crates/foreign-import/src/apps/datagrip/mod.rs create mode 100644 core/crates/foreign-import/src/apps/dbeaver/credentials.rs create mode 100644 core/crates/foreign-import/src/apps/dbeaver/locations.rs create mode 100644 core/crates/foreign-import/src/apps/dbeaver/mod.rs create mode 100644 core/crates/foreign-import/src/apps/dbeaver/scripts.rs create mode 100644 core/crates/foreign-import/src/apps/jdbc.rs create mode 100644 core/crates/foreign-import/src/apps/jetbrains/credential_store.rs create mode 100644 core/crates/foreign-import/src/apps/jetbrains/inner_stream.rs create mode 100644 core/crates/foreign-import/src/apps/jetbrains/kdbx.rs create mode 100644 core/crates/foreign-import/src/apps/jetbrains/mod.rs create mode 100644 core/crates/foreign-import/src/apps/jetbrains/path_macros.rs create mode 100644 core/crates/foreign-import/src/apps/jetbrains/test_kdbx.rs create mode 100644 core/crates/foreign-import/src/apps/jetbrains/xml.rs create mode 100644 core/crates/foreign-import/src/apps/mod.rs create mode 100644 core/crates/foreign-import/src/apps/navicat/cipher.rs create mode 100644 core/crates/foreign-import/src/apps/navicat/mod.rs create mode 100644 core/crates/foreign-import/src/apps/sequel_ace/mod.rs create mode 100644 core/crates/foreign-import/src/apps/sequel_ace/query_favorites.rs create mode 100644 core/crates/foreign-import/src/apps/tableplus/connection.rs create mode 100644 core/crates/foreign-import/src/apps/tableplus/favorites.rs create mode 100644 core/crates/foreign-import/src/apps/tableplus/form_values.rs create mode 100644 core/crates/foreign-import/src/apps/tableplus/linux.rs create mode 100644 core/crates/foreign-import/src/apps/tableplus/locations.rs create mode 100644 core/crates/foreign-import/src/apps/tableplus/mod.rs create mode 100644 core/crates/foreign-import/src/collect.rs create mode 100644 core/crates/foreign-import/src/dirs.rs create mode 100644 core/crates/foreign-import/src/engine.rs create mode 100644 core/crates/foreign-import/src/error.rs create mode 100644 core/crates/foreign-import/src/files.rs create mode 100644 core/crates/foreign-import/src/host.rs create mode 100644 core/crates/foreign-import/src/lib.rs create mode 100644 core/crates/foreign-import/src/record.rs create mode 100644 core/crates/foreign-import/src/review/analyze.rs create mode 100644 core/crates/foreign-import/src/review/apply.rs create mode 100644 core/crates/foreign-import/src/review/mapping.rs create mode 100644 core/crates/foreign-import/src/review/match_key.rs create mode 100644 core/crates/foreign-import/src/review/mod.rs create mode 100644 core/crates/foreign-import/src/review/notes.rs create mode 100644 core/crates/foreign-import/src/review/plan.rs create mode 100644 core/crates/foreign-import/src/review/selection.rs create mode 100644 core/crates/foreign-import/src/sql_file_tree.rs create mode 100644 core/crates/foreign-import/tests/beekeeper.rs create mode 100644 core/crates/foreign-import/tests/datagrip.rs create mode 100644 core/crates/foreign-import/tests/dbeaver.rs create mode 100644 core/crates/foreign-import/tests/fixtures/beekeeper/.key create mode 100644 core/crates/foreign-import/tests/fixtures/beekeeper/connections.sql create mode 100644 core/crates/foreign-import/tests/fixtures/beekeeper/more_connections.sql create mode 100644 core/crates/foreign-import/tests/fixtures/beekeeper/queries.sql create mode 100644 core/crates/foreign-import/tests/fixtures/beekeeper/queries_without_folders.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/.gitignore create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/c.kdbx create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/c.pwd create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.local.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/sshConfigs.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_legacy.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_report.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/.history/db/11111111-1111-4111-8111-111111111111.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/.hidden_console.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/Console_7.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console_1.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console_3.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_report.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/notes.txt create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/reports/dg_sample_monthly.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/script.js create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/22222222-2222-4222-8222-222222222222/dg_sample_orphan.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/33333333-3333-4333-8333-333333333333/console.js create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/44444444-4444-4444-8444-444444444444/dg_unmapped_query.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/scratches/scratch.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/escape/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/escape/dg_sample_outside.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/escape/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/legacy-key/linux/.config/JetBrains/DataGrip2023.1/c.kdbx create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/legacy-key/linux/.config/JetBrains/DataGrip2023.1/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/legacy-key/linux/.config/JetBrains/DataGrip2023.1/pdb.pwd create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roaming/windows/AppData/Roaming/JetBrains/DataGrip2025.2/c.kdbx create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roaming/windows/AppData/Roaming/JetBrains/DataGrip2025.2/c.pwd create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roaming/windows/AppData/Roaming/JetBrains/DataGrip2025.2/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/options/recentProjects.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/projects/default/.idea/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/IntelliJIdea2025.3/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.var/app/com.jetbrains.DataGrip/config/JetBrains/DataGrip2025.2/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/DataGripProjects/shop/.idea/dataSources.local.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/DataGripProjects/shop/.idea/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/xdg-config/JetBrains/DataGrip2025.2/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_q.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_q_2.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/QueryFileSettings.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux-flatpak/.var/app/io.dbeaver.DBeaverCommunity/data/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux-hidden-folder/.local/share/.DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux-snap/snap/dbeaver-ce/current/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux-xdg/xdg-data/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config-2.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config-2.plain.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config.plain.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources-2.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources-broken.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos-empty/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos-invalid/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos-no-connections/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/.dbeaver/credentials-config.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/.dbeaver/credentials-config.plain.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/.dbeaver/project-metadata.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/Scripts/Blank.sql create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/Scripts/Elsewhere.sql create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/Scripts/Empty.sql create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/Scripts/Reports/Daily users.sql create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/Scripts/Script-3.sql create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/Scripts/Script.sql create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/Scripts/Unbound.sql create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/Scripts/notes.txt create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/windows/AppData/Roaming/DBeaverData/workspace6/General/.dbeaver/credentials-config.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/windows/AppData/Roaming/DBeaverData/workspace6/General/.dbeaver/credentials-config.plain.json create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/windows/AppData/Roaming/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/navicat/connections.ncx create mode 100644 core/crates/foreign-import/tests/fixtures/navicat/doctype.ncx create mode 100644 core/crates/foreign-import/tests/fixtures/navicat/empty.ncx create mode 100644 core/crates/foreign-import/tests/fixtures/navicat/malformed.ncx create mode 100644 core/crates/foreign-import/tests/fixtures/sequel_ace/colors/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist create mode 100644 core/crates/foreign-import/tests/fixtures/sequel_ace/corrupt/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist create mode 100644 core/crates/foreign-import/tests/fixtures/sequel_ace/empty/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist create mode 100644 core/crates/foreign-import/tests/fixtures/sequel_ace/full/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist create mode 100644 core/crates/foreign-import/tests/fixtures/sequel_ace/full/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Preferences/com.sequel-ace.sequel-ace.plist create mode 100644 core/crates/foreign-import/tests/fixtures/sequel_ace/no-root/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/Reports/monthly.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/Reports/monthly.sql.tag create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/daily.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/connection_groups.json create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/connections.json create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/settings.json create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/linux/Shared/team.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-empty/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-invalid/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Application Support/com.tinyapp.TablePlus-setapp/Data/Connections.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Application Support/com.tinyapp.TablePlus-setapp/Data/Favorite/setapp.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Preferences/com.tinyapp.TablePlus-setapp.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/tp_shared/shared.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/tp_shared/shared.sql.tag create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Custom/Favorite/custom.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Custom/ignored.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/stale.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Preferences/com.tinyapp.TablePlus.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Moved/ConnectionGroups.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Moved/Connections.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/.ssh/id_tableplus_fixture create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/ConnectionGroups.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/.hidden.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/Sub/deep.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/nested.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/nested.sql.tag create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/notes.txt create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/two.dots.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Untitled.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/orphan.sql.tag create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_root_kw.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_root_kw.sql.tag create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_top.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/ConnectionGroups.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Connections.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Favorite/report.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Favorite/report.sql.tag create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/windows/Queries/Favorite/moved.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/windows/Team Queries/team.sql create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/windows/Team/ConnectionGroups.plist create mode 100644 core/crates/foreign-import/tests/fixtures/tableplus/windows/Team/Connections.plist create mode 100644 core/crates/foreign-import/tests/navicat.rs create mode 100644 core/crates/foreign-import/tests/review.rs create mode 100644 core/crates/foreign-import/tests/sequel_ace.rs create mode 100644 core/crates/foreign-import/tests/tableplus.rs create mode 100644 core/crates/storage/src/connections/additions.rs create mode 100644 core/docs/decisions/0007-importers-from-other-apps.md create mode 100644 linux/crates/app/src/services/foreign_import.rs create mode 100644 linux/crates/app/src/ui/import_from_app/imp.rs create mode 100644 linux/crates/app/src/ui/import_from_app/mod.rs create mode 100644 linux/crates/app/src/ui/import_from_app/tests.rs create mode 100644 linux/crates/app/src/ui/import_from_app/wording.rs create mode 100644 linux/crates/app/src/ui/session_window/import_from_app.rs create mode 100644 linux/data/resources/ui/import-from-app-dialog.ui diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 13ecb9f7b5..6423b4483e 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -54,9 +54,12 @@ jobs: run: | cargo check --locked --target "$TARGET" \ -p tablepro-core -p tablepro-net -p tablepro-storage -p tablepro-ssh -p tablepro-session \ - -p tablepro-ai -p tablepro-mcp -p tablepro-test-support -p tablepro-test-fixtures \ + -p tablepro-foreign-import -p tablepro-ai -p tablepro-mcp -p tablepro-test-support -p tablepro-test-fixtures \ -p tablepro-driver-cassandra -p tablepro-driver-clickhouse -p tablepro-driver-duckdb \ -p tablepro-driver-mongodb -p tablepro-driver-mssql -p tablepro-driver-mysql \ -p tablepro-driver-postgres -p tablepro-driver-redis -p tablepro-driver-sqlite - name: Core unit tests run: cargo test --locked --target "$TARGET" -p tablepro-core --lib + # Each importer reads its app's files from this platform's own places. + - name: Importers from other apps + run: cargo test --locked --target "$TARGET" -p tablepro-foreign-import diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index 5c2f70281a..4a121eeca6 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -24,7 +24,7 @@ The user is trusted: the SQL they type, the connections they create, the plugins 1. Code in this repository that parses server responses: - Swift: `apple/Plugins/*DriverPlugin`, the HTTP and REST engines (Trino, ClickHouse, Etcd, Google REST, DynamoDB, Snowflake, Elasticsearch and others), Teradata's TD2 protocol, Kafka with its Snappy decoder, and the C interop that reads cells out of libpq, libmariadb, FreeTDS, libmongoc, hiredis and libcassandra. - Rust: `core/crates/drivers/*`, `core/crates/net`. -2. File parsers: `apple/Packages/TableProCore/Sources/TableProTabularIO` (its own ZIP reader, XLSX XML, delimited text), `TableProImport` (`.tablepro` bundles, PBKDF2 and AES-GCM), `apple/TablePro/Core/Services/Export/ForeignApp/` (other apps' files, the Navicat cipher, a KDBX reader), `apple/TablePro/Core/Services/ProjectImport/`, `core/crates/core/src/import`. +2. File parsers: `apple/Packages/TableProCore/Sources/TableProTabularIO` (its own ZIP reader, XLSX XML, delimited text), `TableProImport` (`.tablepro` bundles, PBKDF2 and AES-GCM), `apple/TablePro/Core/Services/Export/ForeignApp/` and `core/crates/foreign-import` (other apps' files, the Navicat cipher, a KDBX reader), `apple/TablePro/Core/Services/ProjectImport/`, `core/crates/core/src/import`. 3. Deep links: `apple/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift`, `ExternalConnectionGate.swift`, `apple/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift`. 4. MCP server: `apple/TablePro/Core/MCP/` and `apple/Packages/TableProMacCore/Sources/TableProMCP/` (a hand-written HTTP parser, bearer tokens, PKCE pairing, Origin and Host checks, `MCPStatementGate`). 5. AI tools that run SQL: `apple/TablePro/Core/AI/Chat/Tools/`, `ToolApprovalCenter.swift`. diff --git a/Cargo.lock b/Cargo.lock index b53d06fd5b..f80e0ea27c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,6 +688,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bnum" version = "0.13.0" @@ -918,6 +928,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -4363,6 +4384,18 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "time", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -4547,6 +4580,15 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -4603,7 +4645,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4893,6 +4935,12 @@ dependencies = [ "serde", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.10.0-rc.18" @@ -5068,6 +5116,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6015,6 +6072,7 @@ dependencies = [ "tablepro-driver-postgres", "tablepro-driver-redis", "tablepro-driver-sqlite", + "tablepro-foreign-import", "tablepro-linux-platform", "tablepro-mcp", "tablepro-net", @@ -6286,6 +6344,36 @@ dependencies = [ "uuid", ] +[[package]] +name = "tablepro-foreign-import" +version = "0.0.0" +dependencies = [ + "aes", + "base64 0.22.1", + "blowfish", + "cbc", + "chacha20 0.9.1", + "flate2", + "hex", + "libsqlite3-sys", + "plist", + "roxmltree", + "salsa20", + "secrecy", + "serde", + "serde_json", + "sha1", + "sha2 0.11.0", + "sqlx", + "tablepro-core", + "tablepro-storage", + "tempfile", + "thiserror", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "tablepro-linux-platform" version = "0.0.0" @@ -6296,6 +6384,7 @@ dependencies = [ "oo7", "secrecy", "tablepro-core", + "tablepro-foreign-import", "tablepro-storage", "tempfile", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index fe08a7a370..d15ca6474a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "3" members = [ "core/crates/ai", "core/crates/core", + "core/crates/foreign-import", "core/crates/mcp", "core/crates/net", "core/crates/session", @@ -41,6 +42,7 @@ tablepro-driver-mysql = { path = "core/crates/drivers/mysql" } tablepro-driver-postgres = { path = "core/crates/drivers/postgres" } tablepro-driver-redis = { path = "core/crates/drivers/redis" } tablepro-driver-sqlite = { path = "core/crates/drivers/sqlite" } +tablepro-foreign-import = { path = "core/crates/foreign-import" } tablepro-mcp = { path = "core/crates/mcp" } tablepro-net = { path = "core/crates/net" } tablepro-linux-platform = { path = "linux/crates/platform" } @@ -142,6 +144,19 @@ gio-unix = { version = "0.22", features = ["v2_84"] } glib = { version = "0.22", features = ["v2_88"] } glib-build-tools = "0.22" uuid = { version = "1", features = ["v4", "serde"] } +# What other apps' saved connections are kept in: binary and XML property +# lists, XML, and the fixed-key ciphers those apps use for passwords. +plist = { version = "1.7", default-features = false } +roxmltree = "0.20" +aes = "0.8" +cbc = { version = "0.1", features = ["alloc"] } +blowfish = "0.9" +salsa20 = "0.10" +chacha20 = "0.9" +sha1 = "0.11" +sha2 = "0.11" +hex = "0.4" +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } zeroize = "1.9" # The volume and index that name a file on Windows, which std reads only on nightly. winapi-util = "0.1" diff --git a/core/ARCHITECTURE.md b/core/ARCHITECTURE.md index b9f7056dff..a81b4c221e 100644 --- a/core/ARCHITECTURE.md +++ b/core/ARCHITECTURE.md @@ -13,6 +13,7 @@ core/crates/ ├── ssh/ system OpenSSH transport and the askpass helper (Unix) ├── session/ connect, sessions, health monitor, operation runner ├── storage/ saved connections, history, drafts, credentials, file persistence +├── foreign-import/ connections and saved queries read from other apps' files, and their review ├── ai/ the AI assistant's providers, prompts and replies ├── mcp/ the MCP server: transport, token and tools ├── test-fixtures/ dev-only container fixtures with a generated PKI @@ -35,6 +36,7 @@ core/crates/ |---|---| | `core` | no workspace crate | | `net`, `ssh`, `storage` | `core` | +| `foreign-import` | `core`, `storage` | | `drivers/postgres`, `drivers/mysql`, `drivers/mssql`, `drivers/clickhouse`, `drivers/cassandra`, `drivers/redis`, `drivers/mongodb` | `core`, `net` | | `drivers/sqlite`, `drivers/duckdb` | `core` | | `session` | `core`, `net`, `ssh`, `storage` | @@ -46,6 +48,7 @@ Rules, enforced by review: - `core` depends on **no other workspace crate**. It holds the domain types and the driver contract, and no I/O beyond the traits and the file identity the file engines share. - `net`, `ssh` and `storage` depend on `core` only. +- `foreign-import` depends on `core` and `storage`. It reads other apps' files and never a platform service: a keychain or an installed-app lookup comes in through `ForeignHost`. - Each `drivers/` crate depends on `core`, and on `net` when it speaks TLS or dials TCP. **Drivers never depend on each other, on `session` or on `ssh`.** - `session` depends on `core`, `net`, `ssh` and `storage`, and on no driver outside its tests: it reaches drivers through `DriverRegistry`, and SSH through the `SshTunnels` the app passes in. Its Docker tests take `tablepro-driver-postgres`, `tablepro-driver-redis` and `tablepro-driver-mongodb` as dev-dependencies. - `mcp` depends on `core`, `session` and `storage`, and on no driver outside its tests. It reaches the windows and the user through the `McpHost` trait the app implements. @@ -70,6 +73,7 @@ A value or a trait the app passes in: | `SecretVault` and `SecretItems`, labelled through `SecretLabeler` and `SecretItemLabeler` | `tablepro_linux_platform::SecretStore`, the Secret Service through oo7 | | `SshTunnels` | `tablepro_ssh::SshServices`, the system OpenSSH client | | `ProcessHold` | `gio::Application::hold` | +| `ForeignAppDirs` and `ForeignHost` (`tablepro-foreign-import`) | `tablepro_linux_platform::foreign_app_dirs`, the host's XDG directories inside a Flatpak, and `PlainHost`: no other app's keychain | A `cfg` split inside a crate: diff --git a/core/crates/foreign-import/Cargo.toml b/core/crates/foreign-import/Cargo.toml new file mode 100644 index 0000000000..e1fb27b70b --- /dev/null +++ b/core/crates/foreign-import/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "tablepro-foreign-import" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +tablepro-core.workspace = true +tablepro-storage.workspace = true +aes.workspace = true +base64.workspace = true +blowfish.workspace = true +cbc.workspace = true +chacha20.workspace = true +flate2.workspace = true +hex.workspace = true +# The same SQLite the storage crate links through sqlx, read directly +# because a Beekeeper Studio database is read once, blocking, read-only. +libsqlite3-sys.workspace = true +plist.workspace = true +roxmltree.workspace = true +salsa20.workspace = true +secrecy.workspace = true +serde.workspace = true +serde_json.workspace = true +sha1.workspace = true +sha2.workspace = true +thiserror.workspace = true +tokio-util.workspace = true +tracing.workspace = true +uuid.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +sqlx = { workspace = true, features = ["sqlite-unbundled"] } + +[target.'cfg(not(target_os = "linux"))'.dependencies] +sqlx = { workspace = true, features = ["sqlite"] } + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/core/crates/foreign-import/src/app.rs b/core/crates/foreign-import/src/app.rs new file mode 100644 index 0000000000..c53c23ffde --- /dev/null +++ b/core/crates/foreign-import/src/app.rs @@ -0,0 +1,215 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use tokio_util::sync::CancellationToken; + +use crate::{CollectedImport, ForeignAppDirs, ForeignHost, ForeignImportError, ForeignPlatform, apps}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ForeignApp { + TablePlus, + SequelAce, + DBeaver, + DataGrip, + BeekeeperStudio, + Navicat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PasswordSource { + // The app keeps passwords in the system keychain, which asks the user before another app reads them. + Keychain, + // The app keeps them in its own files under a fixed key, so they are decrypted while reading. + AppFiles, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SavedQuerySupport { + // Every saved query lands in a folder named after the app, for every connection. + GlobalFolder, + // Scripts land with the connection they run on; scripts with a default name start unchecked. + Scripts, + // Query consoles land with their data source; consoles with a default name start unchecked. + Consoles, + // The export file holds none. + NotInExport, +} + +impl ForeignApp { + pub const ALL: [Self; 6] = [ + Self::TablePlus, + Self::SequelAce, + Self::DBeaver, + Self::DataGrip, + Self::BeekeeperStudio, + Self::Navicat, + ]; + + pub fn id(self) -> &'static str { + match self { + Self::TablePlus => "tableplus", + Self::SequelAce => "sequelace", + Self::DBeaver => "dbeaver", + Self::DataGrip => "datagrip", + Self::BeekeeperStudio => "beekeeperstudio", + Self::Navicat => "navicat", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|app| app.id() == id) + } + + pub fn display_name(self) -> &'static str { + match self { + Self::TablePlus => "TablePlus", + Self::SequelAce => "Sequel Ace", + Self::DBeaver => "DBeaver", + Self::DataGrip => "DataGrip", + Self::BeekeeperStudio => "Beekeeper Studio", + Self::Navicat => "Navicat", + } + } + + // Extensions of the export file the user picks, for an importer that reads one rather than the + // app's own store. + pub fn picked_file_extensions(self) -> Option<&'static [&'static str]> { + match self { + Self::Navicat => Some(&["ncx"]), + _ => None, + } + } + + pub fn password_source(self) -> PasswordSource { + match self { + Self::TablePlus | Self::SequelAce | Self::DataGrip => PasswordSource::Keychain, + Self::DBeaver | Self::BeekeeperStudio | Self::Navicat => PasswordSource::AppFiles, + } + } + + pub fn saved_query_support(self) -> SavedQuerySupport { + match self { + Self::TablePlus | Self::SequelAce | Self::BeekeeperStudio => SavedQuerySupport::GlobalFolder, + Self::DBeaver => SavedQuerySupport::Scripts, + Self::DataGrip => SavedQuerySupport::Consoles, + Self::Navicat => SavedQuerySupport::NotInExport, + } + } + + pub fn runs_on(self, platform: ForeignPlatform) -> bool { + self != Self::SequelAce || platform == ForeignPlatform::MacOs + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImportRequest { + pub include_passwords: bool, + pub include_saved_queries: bool, + // A saved query past this many bytes is listed by size only. + pub saved_query_byte_limit: u64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Inventory { + pub connections: usize, + pub saved_queries: usize, +} + +pub(crate) trait AppReader { + // Whether the app's files are where this platform keeps them. + fn has_data(&self) -> bool; + + // Reads files only, never a keychain. + fn inventory(&self) -> Inventory; + + fn collect( + &self, + request: &ImportRequest, + cancel: &CancellationToken, + ) -> Result; +} + +// Every call reads files and may wait on a keychain prompt, so none runs on a UI thread. +#[derive(Clone)] +pub struct ForeignImporter { + app: ForeignApp, + dirs: ForeignAppDirs, + host: Arc, + picked_file: Option, +} + +impl ForeignImporter { + pub fn new(app: ForeignApp, dirs: ForeignAppDirs, host: Arc) -> Self { + Self { + app, + dirs, + host, + picked_file: None, + } + } + + #[must_use] + pub fn with_file(self, path: PathBuf) -> Self { + Self { + picked_file: Some(path), + ..self + } + } + + pub fn app(&self) -> ForeignApp { + self.app + } + + pub fn is_available_blocking(&self) -> bool { + if self.app.picked_file_extensions().is_some() { + return true; + } + if !self.app.runs_on(self.dirs.platform) { + return false; + } + match self.host.installed(self.app) { + Some(true) => true, + // The macOS app looks past a missing bundle only for these two, whose data outlives it. + Some(false) => matches!(self.app, ForeignApp::DBeaver | ForeignApp::DataGrip) && self.reader().has_data(), + None => self.reader().has_data(), + } + } + + pub fn inventory_blocking(&self) -> Inventory { + if !self.app.runs_on(self.dirs.platform) { + return Inventory::default(); + } + self.reader().inventory() + } + + pub fn collect_blocking( + &self, + request: &ImportRequest, + cancel: &CancellationToken, + ) -> Result { + if !self.app.runs_on(self.dirs.platform) { + return Err(ForeignImportError::FileNotFound { app: self.app }); + } + self.reader().collect(request, cancel) + } + + fn reader(&self) -> Box { + let keychain = self.host.keychain(); + match self.app { + ForeignApp::TablePlus => Box::new(apps::tableplus::TablePlus::locate(&self.dirs, keychain)), + ForeignApp::SequelAce => Box::new(apps::sequel_ace::SequelAce::locate(&self.dirs, keychain)), + ForeignApp::DBeaver => Box::new(apps::dbeaver::DBeaver::locate(&self.dirs)), + ForeignApp::DataGrip => Box::new(apps::datagrip::DataGrip::locate(&self.dirs, keychain)), + ForeignApp::BeekeeperStudio => Box::new(apps::beekeeper::Beekeeper::locate(&self.dirs)), + ForeignApp::Navicat => Box::new(apps::navicat::Navicat::new(self.picked_file.clone())), + } + } +} + +pub(crate) fn check_cancel(cancel: &CancellationToken) -> Result<(), ForeignImportError> { + if cancel.is_cancelled() { + Err(ForeignImportError::Cancelled) + } else { + Ok(()) + } +} diff --git a/core/crates/foreign-import/src/apps/beekeeper/encryptor.rs b/core/crates/foreign-import/src/apps/beekeeper/encryptor.rs new file mode 100644 index 0000000000..00e92d42a3 --- /dev/null +++ b/core/crates/foreign-import/src/apps/beekeeper/encryptor.rs @@ -0,0 +1,99 @@ +use aes::Aes256; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use cbc::cipher::block_padding::Pkcs7; +use cbc::cipher::{BlockDecryptMut, KeyIvInit}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +// Beekeeper's built-in key, which only unwraps the per-install key saved in `.key`. +pub(super) const DEFAULT_KEY: &str = "38782F413F442A472D4B6150645367566B59703373367639792442264529482B"; + +const HMAC_HEX_LEN: usize = 64; +const IV_END: usize = HMAC_HEX_LEN + 32; + +// The HMAC goes unchecked, as on macOS: a tampered value fails the padding or the JSON instead. +pub(super) fn decrypt(payload: &str, key: &str) -> Option { + if payload.len() <= IV_END { + return None; + } + let iv = hex::decode(payload.get(HMAC_HEX_LEN..IV_END)?).ok()?; + let ciphertext = STANDARD.decode(payload.get(IV_END..)?).ok()?; + let key = Sha256::digest(key.as_bytes()); + let plaintext = cbc::Decryptor::::new_from_slices(key.as_slice(), &iv) + .ok()? + .decrypt_padded_vec_mut::(&ciphertext) + .ok()?; + serde_json::from_slice(&plaintext).ok() +} + +pub(super) fn decrypt_string(payload: &str, key: &str) -> Option { + match decrypt(payload, key)? { + Value::String(text) => Some(text), + _ => None, + } +} + +// The `.key` file holds `{"encryptionKey": ""}`, and the hex string itself is the key, not its bytes. +pub(super) fn decrypt_key_file(payload: &str) -> Option { + decrypt(payload, DEFAULT_KEY)? + .get("encryptionKey")? + .as_str() + .map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Written by Node's crypto in simple-encryptor's format, with a real HMAC. + const HUNTER2: &str = "1b8a4e2e1f4f66f7f01614a09bc8d12e811f1ab3163882fba392e32e6b4a73fd1af13ccffa292e0ff6a1500816bdab2aCzPG8sqlqQ6/7OGE6av2Ww=="; + const KEY_FILE_ABC123: &str = "d01ab48cdf500e249b7647f64bca32a58fd7521d7f77e8a08485285b1c30e5731eedd684c281ce4bb789154769bfcdaeMGgzH+wBKu/Q2coJZb6jbyLCcZC2T1YHvU/fezI6YAw="; + const SECRET: &str = "f2bb59a5206b8efc899e75bf17dc364f1659f3b1b70eb51ceaab9b6f65efc430ad86947f6ef7eeebe620d49d15fa3310mjl17u3p+aQuSg1zIkt9+w=="; + const NUMBER_42: &str = "d58456133b51bcbe5c0cc0b451bb5bcf5e1f72224d127c2bc27c026fd2db0f44dd2aa8155448c03be212f203a5ccdb4ajYEGViiM8CN5OUT+8MImfw=="; + + #[test] + fn a_string_encrypted_by_simple_encryptor_decrypts() { + assert_eq!( + decrypt_string(HUNTER2, "user-key-1234567890").as_deref(), + Some("hunter2") + ); + } + + #[test] + fn a_dictionary_encrypted_by_simple_encryptor_decrypts() { + let decoded = decrypt(KEY_FILE_ABC123, DEFAULT_KEY).expect("a value"); + assert_eq!(decoded.get("encryptionKey").and_then(Value::as_str), Some("abc123")); + assert_eq!(decrypt_key_file(KEY_FILE_ABC123).as_deref(), Some("abc123")); + } + + #[test] + fn a_malformed_payload_decrypts_to_nothing() { + assert_eq!(decrypt("too-short", "any"), None); + assert_eq!(decrypt(&"z".repeat(200), "any"), None); + let bad_base64 = format!("{}{}", &HUNTER2[..IV_END], "not base64!"); + assert_eq!(decrypt(&bad_base64, "user-key-1234567890"), None); + } + + #[test] + fn the_wrong_key_decrypts_to_nothing() { + assert_eq!(decrypt_string(SECRET, "wrong-key"), None); + assert_eq!(decrypt_string(SECRET, "correct-key").as_deref(), Some("secret")); + } + + #[test] + fn a_value_of_another_type_is_neither_a_password_nor_a_key_file() { + assert_eq!(decrypt(NUMBER_42, "user-key-1234567890"), Some(Value::from(42))); + assert_eq!(decrypt_string(NUMBER_42, "user-key-1234567890"), None); + assert_eq!(decrypt_key_file(HUNTER2), None); + } + + #[test] + fn the_hmac_is_not_checked() { + let tampered = format!("{}{}", "0".repeat(HMAC_HEX_LEN), &HUNTER2[HMAC_HEX_LEN..]); + assert_eq!( + decrypt_string(&tampered, "user-key-1234567890").as_deref(), + Some("hunter2") + ); + } +} diff --git a/core/crates/foreign-import/src/apps/beekeeper/locations.rs b/core/crates/foreign-import/src/apps/beekeeper/locations.rs new file mode 100644 index 0000000000..2e37de4166 --- /dev/null +++ b/core/crates/foreign-import/src/apps/beekeeper/locations.rs @@ -0,0 +1,125 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::{ForeignAppDirs, ForeignPlatform}; + +// Electron's userData folder, named after the package name on every platform. +const FOLDER: &str = "beekeeper-studio"; + +pub(super) const APP_DB: &str = "app.db"; + +// Linux: the host's config folder, `~/.config` if different, the Flathub and release-page Flatpaks, the Snap. +pub(super) fn candidates(dirs: &ForeignAppDirs) -> Vec { + let primary = dirs.config.join(FOLDER); + if dirs.platform != ForeignPlatform::Linux { + return vec![primary]; + } + let home = &dirs.home; + let home_config = home.join(".config").join(FOLDER); + let flatpak = |id: &str| home.join(".var").join("app").join(id).join("config").join(FOLDER); + let mut roots = vec![primary]; + if !roots.contains(&home_config) { + roots.push(home_config); + } + roots.push(flatpak("io.beekeeperstudio.Studio")); + roots.push(flatpak("io.beekeeperstudio.desktop")); + roots.push( + home.join("snap") + .join("beekeeper-studio") + .join("current") + .join(".config") + .join(FOLDER), + ); + roots +} + +// Several packages can leave a copy behind; the one saved to last is the one in use. +pub(super) fn data_dir(dirs: &ForeignAppDirs) -> PathBuf { + let candidates = candidates(dirs); + let mut latest: Option<(SystemTime, &Path)> = None; + for dir in &candidates { + let Ok(metadata) = fs::metadata(dir.join(APP_DB)) else { + continue; + }; + let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); + if latest.is_none_or(|(time, _)| modified > time) { + latest = Some((modified, dir)); + } + } + match latest { + Some((_, dir)) => dir.to_path_buf(), + None => candidates + .into_iter() + .next() + .unwrap_or_else(|| dirs.config.join(FOLDER)), + } +} + +#[cfg(test)] +mod tests { + use std::fs::File; + use std::time::Duration; + + use super::*; + + fn touch(dir: &Path, age: Duration) { + fs::create_dir_all(dir).expect("a folder"); + let file = File::create(dir.join(APP_DB)).expect("a file"); + file.set_modified(SystemTime::now() - age).expect("a time"); + } + + #[test] + fn linux_searches_each_package_in_a_fixed_order() { + let home = Path::new("/home/me"); + let dirs = ForeignAppDirs::conventional(ForeignPlatform::Linux, home); + let expected: Vec = [ + ".config/beekeeper-studio", + ".var/app/io.beekeeperstudio.Studio/config/beekeeper-studio", + ".var/app/io.beekeeperstudio.desktop/config/beekeeper-studio", + "snap/beekeeper-studio/current/.config/beekeeper-studio", + ] + .iter() + .map(|path| home.join(path)) + .collect(); + assert_eq!(candidates(&dirs), expected); + + let custom = ForeignAppDirs { + config: home.join("xdg"), + ..dirs + }; + assert_eq!( + candidates(&custom)[..2], + [home.join("xdg/beekeeper-studio"), home.join(".config/beekeeper-studio")] + ); + assert_eq!(candidates(&custom).len(), 5); + } + + #[test] + fn macos_and_windows_have_one_folder() { + let home = Path::new("/Users/me"); + let mac = ForeignAppDirs::conventional(ForeignPlatform::MacOs, home); + assert_eq!( + candidates(&mac), + [home.join("Library/Application Support/beekeeper-studio")] + ); + let windows = ForeignAppDirs::conventional(ForeignPlatform::Windows, home); + assert_eq!( + candidates(&windows), + [home.join("AppData").join("Roaming").join("beekeeper-studio")] + ); + } + + #[test] + fn the_copy_saved_to_last_wins_and_the_first_folder_stands_in_for_none() { + let home = tempfile::tempdir().expect("a home"); + let dirs = ForeignAppDirs::conventional(ForeignPlatform::Linux, home.path()); + let [native, flathub, _, snap] = [0, 1, 2, 3].map(|index| candidates(&dirs)[index].clone()); + assert_eq!(data_dir(&dirs), native); + + touch(&native, Duration::from_secs(300)); + touch(&snap, Duration::from_secs(60)); + touch(&flathub, Duration::from_secs(120)); + assert_eq!(data_dir(&dirs), snap); + } +} diff --git a/core/crates/foreign-import/src/apps/beekeeper/mod.rs b/core/crates/foreign-import/src/apps/beekeeper/mod.rs new file mode 100644 index 0000000000..e666cda43d --- /dev/null +++ b/core/crates/foreign-import/src/apps/beekeeper/mod.rs @@ -0,0 +1,401 @@ +mod encryptor; +mod locations; +mod saved_queries; +mod sqlite; + +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +use secrecy::{ExposeSecret, SecretString}; +use tokio_util::sync::CancellationToken; + +use self::sqlite::{Database, Row}; +use crate::app::{AppReader, check_cancel}; +use crate::collect::assemble; +use crate::{ + CollectedImport, ForeignApp, ForeignAppDirs, ForeignColor, ForeignConnection, ForeignConnectionRecord, + ForeignCredentials, ForeignImportError, ForeignJumpHost, ForeignSsh, ForeignSshAuth, ForeignSsl, ForeignSslMode, + ImportRequest, Inventory, PasswordOutcome, engine, +}; + +const APP: ForeignApp = ForeignApp::BeekeeperStudio; + +pub(crate) struct Beekeeper { + data_dir: PathBuf, +} + +impl Beekeeper { + pub(crate) fn new(data_dir: PathBuf) -> Self { + Self { data_dir } + } + + pub(crate) fn locate(dirs: &ForeignAppDirs) -> Self { + Self::new(locations::data_dir(dirs)) + } + + fn app_db(&self) -> PathBuf { + self.data_dir.join(locations::APP_DB) + } + + fn open(&self) -> Result { + let path = self.app_db(); + if !path.exists() { + return Err(ForeignImportError::FileNotFound { app: APP }); + } + Database::open_read_only(&path).ok_or_else(|| ForeignImportError::parse("Could not open app.db")) + } + + fn user_encryption_key(&self) -> Option { + let payload = crate::files::read_capped_string(self.data_dir.join(".key")).ok()?; + encryptor::decrypt_key_file(&payload).map(SecretString::from) + } +} + +impl AppReader for Beekeeper { + fn has_data(&self) -> bool { + self.app_db().exists() + } + + fn inventory(&self) -> Inventory { + let Ok(db) = self.open() else { + return Inventory::default(); + }; + Inventory { + // Only the rows collect imports: one with no connection type is skipped there. + connections: read_saved_connections(&db).map_or(0, |rows| { + rows.iter() + .filter(|row| map_driver(row.connection_type.as_deref()).is_some()) + .count() + }), + saved_queries: saved_queries::count(&db), + } + } + + fn collect( + &self, + request: &ImportRequest, + cancel: &CancellationToken, + ) -> Result { + let db = self.open()?; + let rows = read_saved_connections(&db)?; + let folders = read_connection_folders(&db); + let user_key = if request.include_passwords { + self.user_encryption_key() + } else { + None + }; + + let mut records = Vec::with_capacity(rows.len()); + for row in rows { + check_cancel(cancel)?; + let Some(engine) = map_driver(row.connection_type.as_deref()) else { + tracing::warn!("skipping a Beekeeper connection with no connection type"); + continue; + }; + records.push(record(&row, engine, &folders, user_key.as_ref())); + } + + let saved_queries = if request.include_saved_queries { + saved_queries::read(&db, request.saved_query_byte_limit, cancel)? + } else { + Vec::new() + }; + let passwords = if request.include_passwords { + PasswordOutcome::Read + } else { + PasswordOutcome::NotRequested + }; + assemble(APP, records, saved_queries, passwords) + } +} + +fn record( + row: &SavedConnectionRow, + engine: String, + folders: &HashMap, + user_key: Option<&SecretString>, +) -> ForeignConnectionRecord { + let port = row + .port + .and_then(|port| u16::try_from(port).ok()) + .unwrap_or_else(|| engine::default_port(&engine)); + let settings = ForeignConnection { + name: if row.name.is_empty() { + "Untitled".to_owned() + } else { + row.name.clone() + }, + host: if row.host.is_empty() { + "localhost".to_owned() + } else { + row.host.clone() + }, + port, + database: row.default_database.clone().unwrap_or_default(), + username: row.username.clone().unwrap_or_default(), + engine, + ssh: row.ssh_enabled.then(|| ssh(row)), + ssl: row.ssl.then(|| ssl(row)), + color: map_color(row.label_color.as_deref()), + additional_fields: BTreeMap::new(), + }; + ForeignConnectionRecord { + source_id: Some(row.id.to_string()), + group_path: row + .connection_folder_id + .and_then(|id| folders.get(&id)) + .map(|name| vec![name.clone()]) + .unwrap_or_default(), + credentials: user_key.and_then(|key| credentials(row, key.expose_secret())), + settings, + } +} + +// Beekeeper's `ConnectionType` names, from `apps/studio/src/lib/db/types.ts`. +fn map_driver(raw: Option<&str>) -> Option { + let raw = raw.filter(|raw| !raw.is_empty())?; + let mapped = match raw.to_lowercase().as_str() { + "mysql" => "MySQL", + "mariadb" => "MariaDB", + "postgresql" | "postgres" => "PostgreSQL", + "redshift" => "Redshift", + "cockroachdb" => "CockroachDB", + "sqlite" => "SQLite", + "sqlserver" => "SQL Server", + "oracle" => "Oracle", + "mongodb" | "mongo" => "MongoDB", + "redis" => "Redis", + "cassandra" => "Cassandra", + "clickhouse" => "ClickHouse", + "bigquery" => "BigQuery", + "duckdb" => "DuckDB", + "libsql" => "libSQL", + _ => return Some(engine::resolve(raw)), + }; + Some(mapped.to_owned()) +} + +fn ssh(row: &SavedConnectionRow) -> ForeignSsh { + let bastion = row + .ssh_bastion_host + .as_deref() + .filter(|host| !host.is_empty()) + .map(|host| ForeignJumpHost { + host: host.to_owned(), + port: ssh_port(row.ssh_bastion_port), + username: row.ssh_bastion_username.clone().unwrap_or_default(), + auth: map_ssh_auth(row.ssh_bastion_mode.as_deref()), + private_key_path: super::resolve_key_path(row.ssh_bastion_keyfile.as_deref().unwrap_or_default()), + }); + ForeignSsh { + host: row.ssh_host.clone().unwrap_or_default(), + port: ssh_port(row.ssh_port), + username: row.ssh_username.clone().unwrap_or_default(), + auth: map_ssh_auth(row.ssh_mode.as_deref()), + private_key_path: super::resolve_key_path(row.ssh_keyfile.as_deref().unwrap_or_default()), + jump_hosts: bastion.into_iter().collect(), + } +} + +fn ssh_port(port: Option) -> Option { + port.and_then(|port| u16::try_from(port).ok()) +} + +fn map_ssh_auth(mode: Option<&str>) -> ForeignSshAuth { + match mode.map(str::to_lowercase).as_deref() { + Some("keyfile") => ForeignSshAuth::PrivateKey, + Some("agent") => ForeignSshAuth::Agent, + _ => ForeignSshAuth::Password, + } +} + +fn ssl(row: &SavedConnectionRow) -> ForeignSsl { + let mode = if row.ssl_reject_unauthorized && !row.trust_server_certificate { + ForeignSslMode::VerifyIdentity + } else { + ForeignSslMode::Required + }; + ForeignSsl { + mode, + ca_certificate_path: row.ssl_ca_file.clone(), + client_certificate_path: row.ssl_cert_file.clone(), + client_key_path: row.ssl_key_file.clone(), + } +} + +fn map_color(raw: Option<&str>) -> Option { + match raw?.to_lowercase().as_str() { + "red" => Some(ForeignColor::Red), + "orange" => Some(ForeignColor::Orange), + "yellow" => Some(ForeignColor::Yellow), + "green" => Some(ForeignColor::Green), + "blue" => Some(ForeignColor::Blue), + "purple" => Some(ForeignColor::Purple), + "pink" => Some(ForeignColor::Pink), + "gray" | "grey" => Some(ForeignColor::Gray), + _ => None, + } +} + +fn credentials(row: &SavedConnectionRow, key: &str) -> Option { + let decrypt = |value: &Option| { + value + .as_deref() + .filter(|value| !value.is_empty()) + .and_then(|value| encryptor::decrypt_string(value, key)) + .map(SecretString::from) + }; + ForeignCredentials { + password: decrypt(&row.password), + ssh_password: decrypt(&row.ssh_password), + key_passphrase: decrypt(&row.ssh_keyfile_password), + } + .non_empty() +} + +struct SavedConnectionRow { + id: i64, + name: String, + connection_type: Option, + host: String, + port: Option, + username: Option, + default_database: Option, + password: Option, + ssl: bool, + ssl_ca_file: Option, + ssl_cert_file: Option, + ssl_key_file: Option, + ssl_reject_unauthorized: bool, + trust_server_certificate: bool, + ssh_enabled: bool, + ssh_host: Option, + ssh_port: Option, + ssh_username: Option, + ssh_mode: Option, + ssh_keyfile: Option, + ssh_keyfile_password: Option, + ssh_password: Option, + ssh_bastion_host: Option, + ssh_bastion_port: Option, + ssh_bastion_username: Option, + ssh_bastion_mode: Option, + ssh_bastion_keyfile: Option, + label_color: Option, + connection_folder_id: Option, +} + +// A cloud workspace's rows have a positive workspaceId and belong to that workspace's server copy. +const SAVED_CONNECTIONS_SQL: &str = " + SELECT id, name, connectionType, host, port, username, defaultDatabase, password, + ssl, sslCaFile, sslCertFile, sslKeyFile, sslRejectUnauthorized, trustServerCertificate, + sshEnabled, sshHost, sshPort, sshUsername, sshMode, sshKeyfile, sshKeyfilePassword, sshPassword, + sshBastionHost, sshBastionHostPort, sshBastionUsername, sshBastionMode, sshBastionKeyfile, + labelColor, connectionFolderId + FROM saved_connection + WHERE workspaceId = -1 + ORDER BY id"; + +fn read_saved_connections(db: &Database) -> Result, ForeignImportError> { + let mut statement = db + .prepare(SAVED_CONNECTIONS_SQL) + .ok_or_else(|| ForeignImportError::unsupported("saved_connection schema mismatch"))?; + let mut rows = Vec::new(); + while let Some(row) = statement.next_row() { + rows.push(saved_connection(&row)); + } + Ok(rows) +} + +fn saved_connection(row: &Row<'_>) -> SavedConnectionRow { + let flag = |column| row.int64(column) != 0; + SavedConnectionRow { + id: row.int64(0), + name: row.text(1).unwrap_or_default(), + connection_type: row.text(2), + host: row.text(3).unwrap_or_default(), + port: row.optional_int64(4), + username: row.text(5), + default_database: row.text(6), + password: row.text(7), + ssl: flag(8), + ssl_ca_file: row.text(9), + ssl_cert_file: row.text(10), + ssl_key_file: row.text(11), + ssl_reject_unauthorized: flag(12), + trust_server_certificate: flag(13), + ssh_enabled: flag(14), + ssh_host: row.text(15), + ssh_port: row.optional_int64(16), + ssh_username: row.text(17), + ssh_mode: row.text(18), + ssh_keyfile: row.text(19), + ssh_keyfile_password: row.text(20), + ssh_password: row.text(21), + ssh_bastion_host: row.text(22), + ssh_bastion_port: row.optional_int64(23), + ssh_bastion_username: row.text(24), + ssh_bastion_mode: row.text(25), + ssh_bastion_keyfile: row.text(26), + label_color: row.text(27), + connection_folder_id: row.optional_int64(28), + } +} + +fn read_connection_folders(db: &Database) -> HashMap { + let Some(mut statement) = db.prepare("SELECT id, name FROM connection_folder") else { + return HashMap::new(); + }; + let mut folders = HashMap::new(); + while let Some(row) = statement.next_row() { + if let Some(name) = row.text(1).filter(|name| !name.is_empty()) { + folders.insert(row.int64(0), name); + } + } + folders +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn driver_names_map_to_the_macos_engine_names() { + let cases = [ + ("mysql", "MySQL"), + ("MariaDB", "MariaDB"), + ("postgres", "PostgreSQL"), + ("postgresql", "PostgreSQL"), + ("redshift", "Redshift"), + ("cockroachdb", "CockroachDB"), + ("sqlite", "SQLite"), + ("sqlserver", "SQL Server"), + ("oracle", "Oracle"), + ("mongo", "MongoDB"), + ("redis", "Redis"), + ("cassandra", "Cassandra"), + ("clickhouse", "ClickHouse"), + ("bigquery", "BigQuery"), + ("duckdb", "DuckDB"), + ("libsql", "libSQL"), + ]; + for (raw, engine) in cases { + assert_eq!(map_driver(Some(raw)).as_deref(), Some(engine), "{raw}"); + } + assert_eq!(map_driver(Some("SCYLLADB")).as_deref(), Some("ScyllaDB")); + assert_eq!(map_driver(Some("acmedb")).as_deref(), Some("acmedb")); + assert_eq!(map_driver(Some("")), None); + assert_eq!(map_driver(None), None); + } + + #[test] + fn ssh_modes_and_label_colors_map_in_any_case() { + assert_eq!(map_ssh_auth(Some("Keyfile")), ForeignSshAuth::PrivateKey); + assert_eq!(map_ssh_auth(Some("agent")), ForeignSshAuth::Agent); + assert_eq!(map_ssh_auth(Some("userpass")), ForeignSshAuth::Password); + assert_eq!(map_ssh_auth(None), ForeignSshAuth::Password); + assert_eq!(map_color(Some("GREY")), Some(ForeignColor::Gray)); + assert_eq!(map_color(Some("default")), None); + assert_eq!(map_color(None), None); + } +} diff --git a/core/crates/foreign-import/src/apps/beekeeper/saved_queries.rs b/core/crates/foreign-import/src/apps/beekeeper/saved_queries.rs new file mode 100644 index 0000000000..12a105e09e --- /dev/null +++ b/core/crates/foreign-import/src/apps/beekeeper/saved_queries.rs @@ -0,0 +1,208 @@ +use std::collections::{HashMap, HashSet}; + +use tokio_util::sync::CancellationToken; + +use super::sqlite::Database; +use crate::app::check_cancel; +use crate::{ForeignImportError, ForeignSavedQuery, SavedQueryContent}; + +// Every install seeds this example, so importing it would add noise to every library. +const SEEDED_DEMO_TITLE: &str = "Demo Query"; + +struct Folder { + name: String, + parent_id: Option, +} + +pub(super) fn read( + db: &Database, + limit: u64, + cancel: &CancellationToken, +) -> Result, ForeignImportError> { + let columns = column_names(db, "favorite_query"); + if !columns.contains("title") || !columns.contains("text") { + return Ok(Vec::new()); + } + + let folders = if columns.contains("queryFolderId") { + read_folders(db) + } else { + HashMap::new() + }; + let folder_column = if folders.is_empty() { "NULL" } else { "queryFolderId" }; + let sql = format!( + "SELECT title, CASE WHEN length(CAST(text AS BLOB)) > ? THEN NULL ELSE text END, \ + length(CAST(text AS BLOB)), {folder_column} FROM favorite_query {} ORDER BY id", + where_clause(&columns, &[]) + ); + let Some(mut statement) = db.prepare(&sql) else { + return Ok(Vec::new()); + }; + statement.bind_int64(1, i64::try_from(limit).unwrap_or(i64::MAX)); + + let mut queries = Vec::new(); + while let Some(row) = statement.next_row() { + check_cancel(cancel)?; + let title = row.text(0).unwrap_or_default(); + if title == SEEDED_DEMO_TITLE { + continue; + } + let byte_count = u64::try_from(row.int64(2)).unwrap_or(0); + let content = if byte_count > limit { + SavedQueryContent::Oversized { byte_count } + } else { + match row.text(1) { + // The blanks `count` trims in SQL, so the two agree on what a blank query is. + Some(sql) if !sql.trim_matches([' ', '\t', '\n', '\r']).is_empty() => SavedQueryContent::Text(sql), + _ => continue, + } + }; + queries.push(ForeignSavedQuery { + name: title, + content, + keyword: None, + folder_path: folder_path(row.optional_int64(3), &folders), + source_connection_id: None, + is_auto_named: false, + }); + } + Ok(queries) +} + +pub(super) fn count(db: &Database) -> usize { + let columns = column_names(db, "favorite_query"); + if !columns.contains("title") || !columns.contains("text") { + return 0; + } + let conditions = [ + "title IS NOT ?", + "trim(COALESCE(text, ''), ' ' || char(9) || char(10) || char(13)) != ''", + ]; + let sql = format!( + "SELECT COUNT(*) FROM favorite_query {}", + where_clause(&columns, &conditions) + ); + let Some(mut statement) = db.prepare(&sql) else { + return 0; + }; + statement.bind_text(1, SEEDED_DEMO_TITLE); + statement + .next_row() + .map_or(0, |row| usize::try_from(row.int64(0)).unwrap_or(0)) +} + +// Rows of a cloud workspace belong to that workspace's server copy, as for connections. +fn where_clause(columns: &HashSet, conditions: &[&str]) -> String { + let all: Vec<&str> = columns + .contains("workspaceId") + .then_some("workspaceId = -1") + .into_iter() + .chain(conditions.iter().copied()) + .collect(); + if all.is_empty() { + String::new() + } else { + format!("WHERE {}", all.join(" AND ")) + } +} + +fn read_folders(db: &Database) -> HashMap { + let columns = column_names(db, "query_folder"); + if !columns.contains("id") || !columns.contains("name") { + return HashMap::new(); + } + let parent_column = if columns.contains("parentId") { + "parentId" + } else { + "NULL" + }; + let Some(mut statement) = db.prepare(&format!("SELECT id, name, {parent_column} FROM query_folder")) else { + return HashMap::new(); + }; + + let mut folders = HashMap::new(); + while let Some(row) = statement.next_row() { + folders.insert( + row.int64(0), + Folder { + name: row.text(1).unwrap_or_default(), + parent_id: row.optional_int64(2), + }, + ); + } + folders +} + +// A parent chain that loops ends at the first folder seen twice. +fn folder_path(folder_id: Option, folders: &HashMap) -> Vec { + let mut path = Vec::new(); + let mut visited = HashSet::new(); + let mut current = folder_id; + while let Some(id) = current + && visited.insert(id) + && let Some(folder) = folders.get(&id) + { + path.push(folder.name.clone()); + current = folder.parent_id; + } + path.reverse(); + path +} + +fn column_names(db: &Database, table: &str) -> HashSet { + let Some(mut statement) = db.prepare(&format!("PRAGMA table_info({table})")) else { + return HashSet::new(); + }; + let mut names = HashSet::new(); + while let Some(row) = statement.next_row() { + if let Some(name) = row.text(1) { + names.insert(name); + } + } + names +} + +#[cfg(test)] +mod tests { + use super::*; + + fn folder(name: &str, parent_id: Option) -> Folder { + Folder { + name: name.to_owned(), + parent_id, + } + } + + #[test] + fn a_folder_path_runs_from_the_root_down() { + let folders = HashMap::from([(1, folder("Reports", None)), (2, folder("Daily", Some(1)))]); + assert_eq!(folder_path(Some(2), &folders), ["Reports", "Daily"]); + assert_eq!(folder_path(None, &folders), Vec::::new()); + assert_eq!(folder_path(Some(9), &folders), Vec::::new()); + } + + #[test] + fn a_parent_chain_that_loops_stops_at_the_first_folder_seen_twice() { + let folders = HashMap::from([ + (1, folder("A", Some(2))), + (2, folder("B", Some(1))), + (3, folder("C", Some(3))), + ]); + assert_eq!(folder_path(Some(1), &folders), ["B", "A"]); + assert_eq!(folder_path(Some(3), &folders), ["C"]); + } + + #[test] + fn a_missing_parent_ends_the_path() { + let folders = HashMap::from([(2, folder("Daily", Some(7)))]); + assert_eq!(folder_path(Some(2), &folders), ["Daily"]); + } + + #[test] + fn the_workspace_filter_applies_only_when_the_column_exists() { + let with = HashSet::from(["workspaceId".to_owned()]); + assert_eq!(where_clause(&HashSet::new(), &[]), ""); + assert_eq!(where_clause(&with, &[]), "WHERE workspaceId = -1"); + assert_eq!(where_clause(&with, &["a", "b"]), "WHERE workspaceId = -1 AND a AND b"); + } +} diff --git a/core/crates/foreign-import/src/apps/beekeeper/sqlite.rs b/core/crates/foreign-import/src/apps/beekeeper/sqlite.rs new file mode 100644 index 0000000000..947db495ad --- /dev/null +++ b/core/crates/foreign-import/src/apps/beekeeper/sqlite.rs @@ -0,0 +1,135 @@ +use std::ffi::{CStr, CString, c_int}; +use std::marker::PhantomData; +use std::path::Path; +use std::ptr::{self, NonNull}; + +use libsqlite3_sys as ffi; + +pub(super) struct Database { + raw: NonNull, +} + +impl Database { + // Read-only, so no journal file appears in another app's data directory. + pub(super) fn open_read_only(path: &Path) -> Option { + let path = c_path(path)?; + let mut raw = ptr::null_mut(); + let flags = ffi::SQLITE_OPEN_READONLY | ffi::SQLITE_OPEN_NOMUTEX; + // SAFETY: `path` is NUL-terminated and outlives the call, `raw` is a valid out pointer, null picks the VFS. + let code = unsafe { ffi::sqlite3_open_v2(path.as_ptr(), &mut raw, flags, ptr::null()) }; + // A handle comes back even when the open fails, and dropping it closes it. + let database = Self { + raw: NonNull::new(raw)?, + }; + (code == ffi::SQLITE_OK).then_some(database) + } + + pub(super) fn prepare(&self, sql: &str) -> Option> { + let sql = CString::new(sql).ok()?; + let mut raw = ptr::null_mut(); + // SAFETY: the connection is open while `self` lives, `sql` is NUL-terminated, `raw` is a valid out pointer. + let code = unsafe { ffi::sqlite3_prepare_v2(self.raw.as_ptr(), sql.as_ptr(), -1, &mut raw, ptr::null_mut()) }; + let statement = Statement { + raw: NonNull::new(raw)?, + _database: PhantomData, + }; + (code == ffi::SQLITE_OK).then_some(statement) + } +} + +impl Drop for Database { + fn drop(&mut self) { + // SAFETY: closed once, and every statement borrows the database, so all are finalized by now. + unsafe { ffi::sqlite3_close(self.raw.as_ptr()) }; + } +} + +pub(super) struct Statement<'db> { + raw: NonNull, + _database: PhantomData<&'db Database>, +} + +impl Statement<'_> { + pub(super) fn bind_int64(&mut self, index: c_int, value: i64) -> bool { + // SAFETY: the statement is live until drop, and no row borrows it while it binds. + unsafe { ffi::sqlite3_bind_int64(self.raw.as_ptr(), index, value) == ffi::SQLITE_OK } + } + + pub(super) fn bind_text(&mut self, index: c_int, value: &str) -> bool { + let Ok(length) = c_int::try_from(value.len()) else { + return false; + }; + // SAFETY: the pointer and length describe `value`, and SQLITE_TRANSIENT has SQLite copy it before returning. + let code = unsafe { + ffi::sqlite3_bind_text( + self.raw.as_ptr(), + index, + value.as_ptr().cast(), + length, + ffi::SQLITE_TRANSIENT(), + ) + }; + code == ffi::SQLITE_OK + } + + // Columns are read through the row, so none is read once the statement is done. + pub(super) fn next_row(&mut self) -> Option> { + // SAFETY: the statement is live until drop, and a row borrows it mutably, so no column is read mid-step. + let code = unsafe { ffi::sqlite3_step(self.raw.as_ptr()) }; + (code == ffi::SQLITE_ROW).then_some(Row { + statement: self.raw, + _statement: PhantomData, + }) + } +} + +impl Drop for Statement<'_> { + fn drop(&mut self) { + // SAFETY: finalized once, and the borrow of the database keeps the connection open until then. + unsafe { ffi::sqlite3_finalize(self.raw.as_ptr()) }; + } +} + +pub(super) struct Row<'stmt> { + statement: NonNull, + _statement: PhantomData<&'stmt ()>, +} + +impl Row<'_> { + pub(super) fn is_null(&self, column: c_int) -> bool { + // SAFETY: the borrow keeps the statement on this row; SQLite answers NULL for a column out of range. + unsafe { ffi::sqlite3_column_type(self.statement.as_ptr(), column) == ffi::SQLITE_NULL } + } + + pub(super) fn int64(&self, column: c_int) -> i64 { + // SAFETY: as in `is_null`. + unsafe { ffi::sqlite3_column_int64(self.statement.as_ptr(), column) } + } + + pub(super) fn optional_int64(&self, column: c_int) -> Option { + (!self.is_null(column)).then(|| self.int64(column)) + } + + // Stops at the first NUL, as the macOS app reads it. + pub(super) fn text(&self, column: c_int) -> Option { + // SAFETY: as in `is_null`. + let text = unsafe { ffi::sqlite3_column_text(self.statement.as_ptr(), column) }; + if text.is_null() { + return None; + } + // SAFETY: SQLite's text is NUL-terminated and valid until the next step, and it is copied out first. + let text = unsafe { CStr::from_ptr(text.cast()) }; + Some(text.to_string_lossy().into_owned()) + } +} + +#[cfg(unix)] +fn c_path(path: &Path) -> Option { + use std::os::unix::ffi::OsStrExt; + CString::new(path.as_os_str().as_bytes()).ok() +} + +#[cfg(not(unix))] +fn c_path(path: &Path) -> Option { + CString::new(path.to_str()?).ok() +} diff --git a/core/crates/foreign-import/src/apps/datagrip/consoles.rs b/core/crates/foreign-import/src/apps/datagrip/consoles.rs new file mode 100644 index 0000000000..b62f698bc7 --- /dev/null +++ b/core/crates/foreign-import/src/apps/datagrip/consoles.rs @@ -0,0 +1,168 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::app::check_cancel; +use crate::apps::jetbrains::xml; +use crate::sql_file_tree::{self, SqlFileEntry}; +use crate::{ForeignImportError, ForeignSavedQuery}; + +const DEFAULT_BASE_NAME: &str = "console"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConsoleSource { + pub uuid: String, + pub is_mongo: bool, +} + +struct ConsoleFolder<'a> { + source: &'a ConsoleSource, + path: PathBuf, + base_name: String, +} + +pub(crate) fn saved_queries( + sources: &[ConsoleSource], + config_dirs: &[PathBuf], + limit: u64, + cancel: &CancellationToken, +) -> Result, ForeignImportError> { + let mut queries = Vec::new(); + for folder in console_folders(sources, config_dirs) { + for entry in entries(&folder, cancel) { + check_cancel(cancel)?; + let Some(content) = sql_file_tree::content(&entry, limit) else { + continue; + }; + let name = entry.stem(); + queries.push(ForeignSavedQuery { + is_auto_named: is_auto_named(&name, &folder.base_name), + name, + content, + keyword: None, + folder_path: entry.folder_path, + source_connection_id: Some(folder.source.uuid.clone()), + }); + } + } + Ok(queries) +} + +pub(crate) fn count(sources: &[ConsoleSource], config_dirs: &[PathBuf]) -> usize { + let cancel = CancellationToken::new(); + console_folders(sources, config_dirs) + .iter() + .map(|folder| { + entries(folder, &cancel) + .iter() + .filter(|entry| sql_file_tree::is_countable(entry)) + .count() + }) + .sum() +} + +// DataGrip names consoles ``, `_1`, `_2`; the base is a setting that defaults to `console`. +pub(crate) fn is_auto_named(name: &str, base_name: &str) -> bool { + let lowered = name.to_lowercase(); + let base = base_name.to_lowercase(); + if lowered == base { + return true; + } + let Some(number) = lowered.strip_prefix(&base).and_then(|rest| rest.strip_prefix('_')) else { + return false; + }; + !number.is_empty() + && !number.starts_with('0') + && number.len() <= 5 + && number.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn base_name(config_dir: &Path) -> String { + let Ok(data) = crate::files::read_capped(config_dir.join("options").join("QueryFileSettings.xml")) else { + return DEFAULT_BASE_NAME.to_owned(); + }; + let Some(document) = xml::document(&data) else { + return DEFAULT_BASE_NAME.to_owned(); + }; + xml::elements(&document, "option") + .filter(|option| option.attribute("name") == Some("scratchesName")) + .find_map(|option| option.attribute("value")) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map_or_else(|| DEFAULT_BASE_NAME.to_owned(), str::to_owned) +} + +// An IDE upgrade copies consoles into the new config dir, so only the newest copy of each data source +// counts. The uuid comes from a project's dataSources.xml, which a repository can carry, so it becomes +// a path only when it is a UUID. +fn console_folders<'a>(sources: &'a [ConsoleSource], config_dirs: &[PathBuf]) -> Vec> { + let mut seen: HashSet<&str> = HashSet::new(); + let mut base_names: HashMap<&Path, String> = HashMap::new(); + let mut folders = Vec::new(); + for source in sources { + if !is_uuid(&source.uuid) || !seen.insert(source.uuid.as_str()) { + continue; + } + for config_dir in config_dirs { + let path = config_dir.join("consoles").join("db").join(&source.uuid); + if !path.is_dir() { + continue; + } + let base_name = base_names + .entry(config_dir.as_path()) + .or_insert_with(|| base_name(config_dir)) + .clone(); + folders.push(ConsoleFolder { + source, + path, + base_name, + }); + break; + } + } + folders +} + +// Only the hyphenated form, as Foundation's UUID parser takes it. +fn is_uuid(value: &str) -> bool { + value.len() == 36 && Uuid::try_parse(value).is_ok() +} + +fn entries(folder: &ConsoleFolder<'_>, cancel: &CancellationToken) -> Vec { + let is_mongo = folder.source.is_mongo; + let accepts = move |file_name: &str| match Path::new(file_name) + .extension() + .and_then(|extension| extension.to_str()) + { + Some(extension) if extension.eq_ignore_ascii_case("sql") => true, + Some(extension) if extension.eq_ignore_ascii_case("js") => is_mongo, + _ => false, + }; + sql_file_tree::entries(&folder.path, &accepts, cancel) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn console_names_datagrip_generates_are_recognized() { + assert!(is_auto_named("console", "console")); + assert!(is_auto_named("console_1", "console")); + assert!(is_auto_named("Console_99999", "console")); + assert!(!is_auto_named("console_0", "console")); + assert!(!is_auto_named("console_100000", "console")); + assert!(!is_auto_named("console_", "console")); + assert!(!is_auto_named("consoles", "console")); + } + + #[test] + fn only_a_hyphenated_uuid_names_a_console_folder() { + assert!(is_uuid("11111111-1111-4111-8111-111111111111")); + assert!(!is_uuid("11111111111141118111111111111111")); + assert!(!is_uuid("{11111111-1111-4111-8111-111111111111}")); + assert!(!is_uuid("../escape")); + } +} diff --git a/core/crates/foreign-import/src/apps/datagrip/data_sources.rs b/core/crates/foreign-import/src/apps/datagrip/data_sources.rs new file mode 100644 index 0000000000..50e14f2ade --- /dev/null +++ b/core/crates/foreign-import/src/apps/datagrip/data_sources.rs @@ -0,0 +1,245 @@ +// DataGrip splits one data source across the shared `dataSources.xml` (driver, JDBC URL, group) and +// the machine-local `dataSources.local.xml` (user name, SSH, SSL); fragments of both merge by uuid. + +use std::collections::HashMap; +use std::path::Path; + +use roxmltree::Node; + +use crate::apps::jetbrains::{expand, xml}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DataSource { + pub uuid: String, + pub name: String, + pub driver_ref: String, + pub jdbc_url: String, + pub username: String, + pub group_name: Option, + pub ssh: Option, + pub ssl: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct Fragment { + pub uuid: String, + pub name: Option, + pub driver_ref: Option, + pub jdbc_url: Option, + pub username: Option, + pub group_name: Option, + pub ssh: Option, + pub ssl: Option, +} + +// Only an enabled `ssh-properties` becomes a reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SshReference { + pub config_id: Option, + pub inline_host: Option, + pub inline_port: Option, + pub inline_user: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SslProperties { + pub mode: Option, + pub ca_cert_path: Option, + pub client_cert_path: Option, + pub client_key_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SshConfig { + pub host: String, + pub port: Option, + pub username: String, + pub auth_type: Option, + pub key_path: Option, +} + +impl Fragment { + pub(crate) fn merge(&mut self, other: Self) { + fn take(target: &mut Option, value: Option) { + if value.is_some() { + *target = value; + } + } + take(&mut self.name, other.name); + take(&mut self.driver_ref, other.driver_ref); + take(&mut self.jdbc_url, other.jdbc_url); + take(&mut self.username, other.username); + take(&mut self.group_name, other.group_name); + take(&mut self.ssh, other.ssh); + take(&mut self.ssl, other.ssl); + } + + pub(crate) fn resolved(&self) -> Option { + let driver_ref = self.driver_ref.clone().filter(|value| !value.is_empty())?; + let jdbc_url = self.jdbc_url.clone().filter(|value| !value.is_empty())?; + Some(DataSource { + uuid: self.uuid.clone(), + name: self.name.clone().unwrap_or_else(|| self.uuid.clone()), + driver_ref, + jdbc_url, + username: self.username.clone().unwrap_or_default(), + group_name: self.group_name.clone(), + ssh: self.ssh.clone(), + ssl: self.ssl.clone(), + }) + } +} + +pub(crate) fn parse_fragments(data: &[u8], home: &Path) -> Vec { + let Some(document) = xml::document(data) else { + return Vec::new(); + }; + xml::elements(&document, "data-source") + .filter_map(|element| { + let uuid = element.attribute("uuid")?; + Some(parse_fragment(element, uuid, home)) + }) + .collect() +} + +// A later `sshConfig` with the same id replaces an earlier one. +pub(crate) fn parse_ssh_configs(data: &[u8], home: &Path) -> HashMap { + let Some(document) = xml::document(data) else { + return HashMap::new(); + }; + xml::elements(&document, "sshConfig") + .filter_map(|element| { + let id = element.attribute("id")?; + let config = SshConfig { + host: element.attribute("host").unwrap_or_default().to_owned(), + port: element.attribute("port").and_then(|port| port.parse().ok()), + username: element.attribute("username").unwrap_or_default().to_owned(), + auth_type: element.attribute("authType").map(str::to_owned), + key_path: element.attribute("keyPath").map(|path| expand(path, home)), + }; + Some((id.to_owned(), config)) + }) + .collect() +} + +fn parse_fragment(element: Node<'_, '_>, uuid: &str, home: &Path) -> Fragment { + Fragment { + uuid: uuid.to_owned(), + name: non_empty(element.attribute("name").map(str::to_owned)), + driver_ref: xml::child_text(element, "driver-ref"), + jdbc_url: non_empty(xml::child_text(element, "jdbc-url")), + username: non_empty(xml::child_text(element, "user-name")), + group_name: non_empty(element.attribute("group-name").map(str::to_owned)), + ssh: parse_ssh_reference(element), + ssl: parse_ssl_properties(element, home), + } +} + +fn parse_ssh_reference(element: Node<'_, '_>) -> Option { + let ssh = xml::child(element, "ssh-properties")?; + let enabled = xml::child_text(ssh, "enabled").or_else(|| ssh.attribute("enabled").map(str::to_owned)); + if enabled.as_deref() != Some("true") { + return None; + } + let config_id = xml::child_text(ssh, "ssh-config-id").or_else(|| ssh.attribute("ssh-config-id").map(str::to_owned)); + Some(SshReference { + config_id: non_empty(config_id), + inline_host: ssh.attribute("host").map(str::to_owned), + inline_port: ssh.attribute("port").and_then(|port| port.parse().ok()), + inline_user: ssh + .attribute("user") + .or_else(|| ssh.attribute("username")) + .map(str::to_owned), + }) +} + +fn parse_ssl_properties(element: Node<'_, '_>, home: &Path) -> Option { + let ssl = xml::child(element, "ssl-config")?; + if xml::child_text(ssl, "enabled").as_deref() != Some("true") { + return None; + } + let cert_path = |name: &str| non_empty(xml::child_text(ssl, name)).map(|path| expand(&path, home)); + Some(SslProperties { + mode: xml::child_text(ssl, "mode"), + ca_cert_path: cert_path("ca-cert"), + client_cert_path: cert_path("client-cert"), + client_key_path: cert_path("client-key"), + }) +} + +fn non_empty(value: Option) -> Option { + value.filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const HOME: &str = "/home/me"; + + fn fragments(xml: &str) -> Vec { + parse_fragments(xml.as_bytes(), Path::new(HOME)) + } + + #[test] + fn a_fragment_needs_a_uuid_and_resolves_only_with_a_driver_and_url() { + let parsed = fragments(concat!( + "", + "mysqljdbc:mysql://h/a", + "mysql jdbc:mysql://h/a ", + "jdbc:mysql://h/a", + "" + )); + assert_eq!(parsed.len(), 2); + let first = parsed[0].resolved().expect("a data source"); + assert_eq!(first.name, "u1"); + assert_eq!(first.jdbc_url, "jdbc:mysql://h/a"); + assert_eq!(parsed[1].resolved(), None); + } + + #[test] + fn a_later_fragment_replaces_only_the_fields_it_carries() { + let mut shared = fragments( + "pgjdbc:postgresql://h/db", + ) + .remove(0); + let local = fragments("me").remove(0); + shared.merge(local); + let source = shared.resolved().expect("a data source"); + assert_eq!(source.name, "Shared"); + assert_eq!(source.group_name.as_deref(), Some("G")); + assert_eq!(source.username, "me"); + } + + #[test] + fn ssh_properties_read_from_children_or_attributes_and_only_when_enabled() { + let parsed = fragments(concat!( + "", + "trueS", + "", + "", + "" + )); + let first = parsed[0].ssh.clone().expect("ssh"); + assert_eq!(first.config_id.as_deref(), Some("S")); + let second = parsed[1].ssh.clone().expect("ssh"); + assert_eq!(second.config_id, None); + assert_eq!(second.inline_host.as_deref(), Some("b")); + assert_eq!(second.inline_port, Some(2200)); + assert_eq!(second.inline_user.as_deref(), Some("u")); + assert_eq!(parsed[2].ssh, None); + } + + #[test] + fn ssh_configs_are_keyed_by_id_and_expand_the_key_path() { + let configs = parse_ssh_configs( + b"", + Path::new(HOME), + ); + assert_eq!(configs.len(), 1); + let config = &configs["A"]; + assert_eq!(config.port, None); + assert_eq!(config.username, ""); + assert_eq!(config.key_path.as_deref(), Some("/home/me/.ssh/id")); + } +} diff --git a/core/crates/foreign-import/src/apps/datagrip/locations.rs b/core/crates/foreign-import/src/apps/datagrip/locations.rs new file mode 100644 index 0000000000..80562db13c --- /dev/null +++ b/core/crates/foreign-import/src/apps/datagrip/locations.rs @@ -0,0 +1,187 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::apps::jetbrains::{expand, xml}; +use crate::{ForeignAppDirs, ForeignPlatform}; + +const FLATPAK_ID: &str = "com.jetbrains.DataGrip"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Location { + pub data_sources: PathBuf, + pub local: Option, + pub config_dir: PathBuf, +} + +// The folders that hold `DataGrip` config dirs. On Linux the IDE follows $XDG_CONFIG_HOME, +// and the Flathub build keeps its own under ~/.var/app. +pub(crate) fn jetbrains_roots(dirs: &ForeignAppDirs) -> Vec { + let primary = dirs.config.join("JetBrains"); + match dirs.platform { + ForeignPlatform::MacOs | ForeignPlatform::Windows => vec![primary], + ForeignPlatform::Linux => { + let home_config = dirs.home.join(".config").join("JetBrains"); + let flatpak = dirs + .home + .join(".var") + .join("app") + .join(FLATPAK_ID) + .join("config") + .join("JetBrains"); + let mut roots = vec![primary]; + if home_config != roots[0] { + roots.push(home_config); + } + roots.push(flatpak); + roots + } + } +} + +// Newest version first across every root, so the first copy of a data source wins; a name in two +// roots keeps the order of the roots. +pub(crate) fn config_dirs(roots: &[PathBuf]) -> Vec { + let mut found: Vec<(String, PathBuf)> = Vec::new(); + for root in roots { + let Ok(children) = fs::read_dir(root) else { + continue; + }; + for child in children.filter_map(Result::ok) { + if let Ok(name) = child.file_name().into_string() + && name.starts_with("DataGrip") + { + found.push((name, child.path())); + } + } + } + found.sort_by(|left, right| right.0.cmp(&left.0)); + found.into_iter().map(|(_, path)| path).collect() +} + +pub(crate) fn locations(config_dirs: &[PathBuf], home: &Path) -> Vec { + let mut result = Vec::new(); + for config_dir in config_dirs { + append(&config_dir.join("options"), config_dir, &mut result); + for project in project_dirs(&config_dir.join("projects")) { + append(&project.join(".idea"), config_dir, &mut result); + } + for project in recent_project_paths(config_dir, home) { + append(&PathBuf::from(project).join(".idea"), config_dir, &mut result); + } + } + result +} + +fn project_dirs(projects: &Path) -> Vec { + let Ok(children) = fs::read_dir(projects) else { + return Vec::new(); + }; + let mut found: Vec = children + .filter_map(Result::ok) + .filter(|child| !child.file_name().to_string_lossy().starts_with('.')) + .map(|child| child.path()) + .collect(); + found.sort(); + found +} + +fn append(directory: &Path, config_dir: &Path, result: &mut Vec) { + let data_sources = directory.join("dataSources.xml"); + if !data_sources.exists() { + return; + } + let local = directory.join("dataSources.local.xml"); + result.push(Location { + data_sources, + local: local.exists().then_some(local), + config_dir: config_dir.to_path_buf(), + }); +} + +fn recent_project_paths(config_dir: &Path, home: &Path) -> Vec { + let Ok(data) = crate::files::read_capped(config_dir.join("options").join("recentProjects.xml")) else { + return Vec::new(); + }; + let Some(document) = xml::document(&data) else { + return Vec::new(); + }; + xml::elements(&document, "entry") + .filter_map(|entry| entry.attribute("key")) + .map(|key| expand(key, home)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dirs(platform: ForeignPlatform, home: &Path, config: &Path) -> ForeignAppDirs { + ForeignAppDirs { + platform, + home: home.to_path_buf(), + config: config.to_path_buf(), + data: home.join("data"), + } + } + + #[test] + fn linux_searches_the_xdg_folder_the_home_config_folder_and_the_flatpak_folder() { + let home = Path::new("/home/me"); + let roots = jetbrains_roots(&dirs(ForeignPlatform::Linux, home, &home.join("xdg"))); + assert_eq!( + roots, + [ + home.join("xdg/JetBrains"), + home.join(".config/JetBrains"), + home.join(".var/app/com.jetbrains.DataGrip/config/JetBrains"), + ] + ); + let conventional = jetbrains_roots(&ForeignAppDirs::conventional(ForeignPlatform::Linux, home)); + assert_eq!( + conventional, + [ + home.join(".config/JetBrains"), + home.join(".var/app/com.jetbrains.DataGrip/config/JetBrains") + ] + ); + } + + #[test] + fn macos_and_windows_search_the_config_folder_only() { + let home = Path::new("/Users/me"); + let mac = ForeignAppDirs::conventional(ForeignPlatform::MacOs, home); + assert_eq!( + jetbrains_roots(&mac), + [home.join("Library/Application Support/JetBrains")] + ); + let windows = ForeignAppDirs::conventional(ForeignPlatform::Windows, home); + assert_eq!( + jetbrains_roots(&windows), + [home.join("AppData").join("Roaming").join("JetBrains")] + ); + } + + #[test] + fn config_dirs_run_newest_first_across_roots_and_ties_keep_root_order() { + let base = tempfile::tempdir().expect("a temp dir"); + let first = base.path().join("first"); + let second = base.path().join("second"); + for path in [ + first.join("DataGrip2025.1"), + first.join("IntelliJIdea2025.3"), + second.join("DataGrip2025.3"), + second.join("DataGrip2025.1"), + ] { + fs::create_dir_all(path).expect("a folder"); + } + let found = config_dirs(&[first.clone(), second.clone(), base.path().join("missing")]); + assert_eq!( + found, + [ + second.join("DataGrip2025.3"), + first.join("DataGrip2025.1"), + second.join("DataGrip2025.1") + ] + ); + } +} diff --git a/core/crates/foreign-import/src/apps/datagrip/mod.rs b/core/crates/foreign-import/src/apps/datagrip/mod.rs new file mode 100644 index 0000000000..52c2b6ccbc --- /dev/null +++ b/core/crates/foreign-import/src/apps/datagrip/mod.rs @@ -0,0 +1,517 @@ +mod consoles; +mod data_sources; +mod locations; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tokio_util::sync::CancellationToken; + +use self::consoles::ConsoleSource; +use self::data_sources::{DataSource, Fragment, SshConfig, SshReference, SslProperties}; +use self::locations::Location; +use crate::app::{AppReader, check_cancel}; +use crate::apps::jetbrains::{CredentialStore, Lookup}; +use crate::apps::{jdbc, resolve_key_path}; +use crate::collect::{PasswordOutcome, assemble}; +use crate::{ + CollectedImport, ForeignApp, ForeignAppDirs, ForeignConnection, ForeignConnectionRecord, ForeignCredentials, + ForeignImportError, ForeignKeychain, ForeignPlatform, ForeignSsh, ForeignSshAuth, ForeignSsl, ForeignSslMode, + ImportRequest, Inventory, engine, +}; + +pub(crate) struct DataGrip { + roots: Vec, + home: PathBuf, + platform: ForeignPlatform, + keychain: Option>, +} + +struct ScannedSource { + source: DataSource, + config_dir: PathBuf, +} + +#[derive(Default)] +struct Scan { + sources: Vec, + // Every uuid DataGrip lists, including one that maps to no connection. + console_sources: Vec, +} + +struct CollectedCredentials { + credentials: Option, + stopped: bool, +} + +impl DataGrip { + pub(crate) fn new( + roots: Vec, + home: PathBuf, + platform: ForeignPlatform, + keychain: Option>, + ) -> Self { + Self { + roots, + home, + platform, + keychain, + } + } + + pub(crate) fn locate(dirs: &ForeignAppDirs, keychain: Option>) -> Self { + Self::new( + locations::jetbrains_roots(dirs), + dirs.home.clone(), + dirs.platform, + keychain, + ) + } + + fn config_dirs(&self) -> Vec { + locations::config_dirs(&self.roots) + } + + // Locations run newest config dir first, so the first copy of a uuid wins. + fn scan(&self, locations: &[Location]) -> Scan { + let mut resolved: HashMap = HashMap::new(); + let mut fragments: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); + + for location in locations { + for fragment in fragments_at(location, &self.home) { + if !resolved.contains_key(&fragment.uuid) + && let Some(source) = fragment.resolved() + { + resolved.insert(fragment.uuid.clone(), sources.len()); + sources.push(ScannedSource { + source, + config_dir: location.config_dir.clone(), + }); + } + if !fragments.contains_key(&fragment.uuid) { + order.push(fragment.uuid.clone()); + fragments.insert(fragment.uuid.clone(), fragment); + } + } + } + + let console_sources = order + .into_iter() + .filter_map(|uuid| { + let fragment = fragments.get(&uuid)?; + let source = resolved + .get(&uuid) + .and_then(|&index| sources.get(index)) + .map(|scanned| &scanned.source); + let driver_ref = source + .map(|source| source.driver_ref.as_str()) + .or(fragment.driver_ref.as_deref()); + let jdbc_url = source + .map(|source| source.jdbc_url.as_str()) + .or(fragment.jdbc_url.as_deref()); + Some(ConsoleSource { + is_mongo: is_mongo(driver_ref, jdbc_url), + uuid, + }) + }) + .collect(); + Scan { + sources, + console_sources, + } + } + + fn credential_store(&self, config_dir: &Path) -> CredentialStore { + CredentialStore::new(config_dir.to_path_buf(), self.platform, self.keychain.clone()) + } + + fn password_outcome( + &self, + request: &ImportRequest, + stopped: bool, + stores: &HashMap, + ) -> PasswordOutcome { + if !request.include_passwords { + PasswordOutcome::NotRequested + } else if stopped { + PasswordOutcome::Stopped + } else if self.keychain.is_none() && stores.values().any(|store| !store.kdbx_loaded()) { + PasswordOutcome::LeftInKeychain + } else { + PasswordOutcome::Read + } + } +} + +impl AppReader for DataGrip { + fn has_data(&self) -> bool { + !locations::locations(&self.config_dirs(), &self.home).is_empty() + } + + fn inventory(&self) -> Inventory { + let config_dirs = self.config_dirs(); + let scan = self.scan(&locations::locations(&config_dirs, &self.home)); + Inventory { + connections: scan.sources.len(), + saved_queries: consoles::count(&scan.console_sources, &config_dirs), + } + } + + fn collect( + &self, + request: &ImportRequest, + cancel: &CancellationToken, + ) -> Result { + let config_dirs = self.config_dirs(); + let locations = locations::locations(&config_dirs, &self.home); + if locations.is_empty() { + return Err(ForeignImportError::FileNotFound { + app: ForeignApp::DataGrip, + }); + } + + let scan = self.scan(&locations); + let mut records = Vec::with_capacity(scan.sources.len()); + let mut stopped = false; + let mut ssh_configs_by_dir: HashMap> = HashMap::new(); + let mut stores: HashMap = HashMap::new(); + + for scanned in &scan.sources { + check_cancel(cancel)?; + let config_dir = &scanned.config_dir; + let ssh_configs = ssh_configs_by_dir + .entry(config_dir.clone()) + .or_insert_with(|| load_ssh_configs(config_dir, &self.home)); + + let mut credentials = None; + if request.include_passwords && !stopped { + let store = stores + .entry(config_dir.clone()) + .or_insert_with(|| self.credential_store(config_dir)); + let collected = collect_credentials(&scanned.source, ssh_configs, store); + credentials = collected.credentials; + stopped = collected.stopped; + } + + records.push(ForeignConnectionRecord { + source_id: Some(scanned.source.uuid.clone()), + settings: make_connection(&scanned.source, ssh_configs), + group_path: scanned.source.group_name.iter().cloned().collect(), + credentials, + }); + } + + let saved_queries = if request.include_saved_queries { + consoles::saved_queries( + &scan.console_sources, + &config_dirs, + request.saved_query_byte_limit, + cancel, + )? + } else { + Vec::new() + }; + let passwords = self.password_outcome(request, stopped, &stores); + assemble(ForeignApp::DataGrip, records, saved_queries, passwords) + } +} + +// The shared file carries the driver and URL, the local file the user name, SSH and SSL; per field the +// local file wins. +fn fragments_at(location: &Location, home: &Path) -> Vec { + let mut by_uuid: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + for path in std::iter::once(&location.data_sources).chain(location.local.as_ref()) { + let Ok(data) = crate::files::read_capped(path) else { + continue; + }; + for fragment in data_sources::parse_fragments(&data, home) { + match by_uuid.get_mut(&fragment.uuid) { + Some(existing) => existing.merge(fragment), + None => { + order.push(fragment.uuid.clone()); + by_uuid.insert(fragment.uuid.clone(), fragment); + } + } + } + } + order.into_iter().filter_map(|uuid| by_uuid.remove(&uuid)).collect() +} + +fn load_ssh_configs(config_dir: &Path, home: &Path) -> HashMap { + crate::files::read_capped(config_dir.join("options").join("sshConfigs.xml")) + .map(|data| data_sources::parse_ssh_configs(&data, home)) + .unwrap_or_default() +} + +// `stopped` means the keychain refused or the KeePass file is locked, so no later row is read. +fn collect_credentials( + source: &DataSource, + ssh_configs: &HashMap, + store: &mut CredentialStore, +) -> CollectedCredentials { + let mut credentials = ForeignCredentials::default(); + let mut stopped = false; + + match store.password(&source.uuid) { + Lookup::Found(value) => credentials.password = Some(value), + Lookup::Stopped => stopped = true, + Lookup::NotFound => {} + } + + let ssh = source + .ssh + .as_ref() + .and_then(|reference| reference.config_id.as_deref()) + .and_then(|id| ssh_configs.get(id).map(|config| (id, config))); + if !stopped && let Some((config_id, config)) = ssh { + let port = config.port.unwrap_or(22); + let uses_key = uses_key_authentication( + config.auth_type.as_deref(), + config.key_path.as_deref().unwrap_or_default(), + ); + let lookup = if uses_key { + store.ssh_key_passphrase(&config.host, port, config_id) + } else { + store.ssh_password(&config.host, port, config_id) + }; + match lookup { + Lookup::Found(value) if uses_key => credentials.key_passphrase = Some(value), + Lookup::Found(value) => credentials.ssh_password = Some(value), + Lookup::Stopped => stopped = true, + Lookup::NotFound => {} + } + } + + CollectedCredentials { + credentials: credentials.non_empty(), + stopped, + } +} + +fn make_connection(source: &DataSource, ssh_configs: &HashMap) -> ForeignConnection { + let subprotocol = jdbc::subprotocol(&source.jdbc_url); + let engine = map_driver_ref(&source.driver_ref, &subprotocol); + let endpoint = jdbc::parse(&source.jdbc_url, &subprotocol); + let port = endpoint + .as_ref() + .and_then(|endpoint| endpoint.port) + .unwrap_or_else(|| engine::default_port(&engine)); + let (host, database, url_user) = match endpoint { + Some(endpoint) => (endpoint.host, endpoint.database, endpoint.username), + None => ("localhost".to_owned(), String::new(), None), + }; + let additional_fields = jdbc::url_fields(&source.jdbc_url); + // The macOS port read TLS from the XML alone, so a URL that asks for it imported in the clear. + let ssl = source + .ssl + .as_ref() + .map(make_ssl) + .or_else(|| jdbc::url_ssl(&source.jdbc_url)); + ForeignConnection { + name: source.name.clone(), + host, + port, + database, + username: Some(source.username.clone()) + .filter(|user| !user.is_empty()) + .or(url_user) + .unwrap_or_default(), + engine, + ssh: make_ssh(source.ssh.as_ref(), ssh_configs), + ssl, + color: None, + additional_fields, + } +} + +fn make_ssh(reference: Option<&SshReference>, ssh_configs: &HashMap) -> Option { + let reference = reference?; + let config = reference.config_id.as_ref().and_then(|id| ssh_configs.get(id)); + let host = config + .map(|config| config.host.clone()) + .or_else(|| reference.inline_host.clone()) + .unwrap_or_default(); + if host.is_empty() { + return None; + } + let key_path = config.and_then(|config| config.key_path.as_deref()).unwrap_or_default(); + let uses_key = uses_key_authentication(config.and_then(|config| config.auth_type.as_deref()), key_path); + Some(ForeignSsh { + host, + port: config.and_then(|config| config.port).or(reference.inline_port), + username: config + .map(|config| config.username.clone()) + .or_else(|| reference.inline_user.clone()) + .unwrap_or_default(), + auth: if uses_key { + ForeignSshAuth::PrivateKey + } else { + ForeignSshAuth::Password + }, + private_key_path: if uses_key { + resolve_key_path(key_path) + } else { + String::new() + }, + jump_hosts: Vec::new(), + }) +} + +// DataGrip omits `authType` when the connection relies on the OpenSSH config, so a key path is the +// signal for key authentication. +fn uses_key_authentication(auth_type: Option<&str>, key_path: &str) -> bool { + match auth_type.unwrap_or_default().to_uppercase().as_str() { + "KEY_PAIR" | "PUBLIC_KEY" | "OPEN_SSH" => true, + "PASSWORD" => false, + _ => !key_path.is_empty(), + } +} + +fn make_ssl(ssl: &SslProperties) -> ForeignSsl { + let mode = match ssl.mode.as_deref().unwrap_or_default().to_lowercase().as_str() { + "require" | "required" => ForeignSslMode::Required, + "verify_ca" | "verify-ca" => ForeignSslMode::VerifyCa, + "verify_full" | "verify-full" => ForeignSslMode::VerifyIdentity, + _ => ForeignSslMode::Preferred, + }; + ForeignSsl { + mode, + ca_certificate_path: ssl.ca_cert_path.clone(), + client_certificate_path: ssl.client_cert_path.clone(), + client_key_path: ssl.client_key_path.clone(), + } +} + +fn is_mongo(driver_ref: Option<&str>, jdbc_url: Option<&str>) -> bool { + let url = jdbc_url.unwrap_or_default(); + if url.to_lowercase().starts_with("mongodb") { + return true; + } + map_driver_ref(driver_ref.unwrap_or_default(), &jdbc::subprotocol(url)) == "MongoDB" +} + +fn map_driver_ref(driver_ref: &str, subprotocol: &str) -> String { + let lowered = driver_ref.to_lowercase(); + let token = lowered.split('.').find(|part| !part.is_empty()).unwrap_or(&lowered); + let engine = match token { + "mysql" => "MySQL", + "mariadb" => "MariaDB", + "postgresql" | "postgres" => "PostgreSQL", + "sqlite" => "SQLite", + "sqlserver" | "mssql" | "jtds" => "SQL Server", + "oracle" => "Oracle", + "mongo" | "mongodb" => "MongoDB", + "redis" => "Redis", + "clickhouse" => "ClickHouse", + "cassandra" => "Cassandra", + "duckdb" => "DuckDB", + "bigquery" => "BigQuery", + "cockroach" | "cockroachdb" => "CockroachDB", + "redshift" => "Redshift", + _ => return map_subprotocol(subprotocol, driver_ref), + }; + engine.to_owned() +} + +fn map_subprotocol(subprotocol: &str, fallback: &str) -> String { + let engine = match subprotocol.to_lowercase().as_str() { + "mysql" => "MySQL", + "mariadb" => "MariaDB", + "postgresql" => "PostgreSQL", + "sqlite" => "SQLite", + "sqlserver" | "jtds" => "SQL Server", + "oracle" => "Oracle", + "mongodb" => "MongoDB", + "redis" => "Redis", + "clickhouse" => "ClickHouse", + "cassandra" => "Cassandra", + "duckdb" => "DuckDB", + "bigquery" => "BigQuery", + _ => return engine::resolve(fallback), + }; + engine.to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_driver_ref_decides_the_engine_before_the_url() { + assert_eq!(map_driver_ref("mysql.8", "mysql"), "MySQL"); + assert_eq!(map_driver_ref("sqlserver.ms", "sqlserver"), "SQL Server"); + assert_eq!(map_driver_ref("sqlite.xerial", "sqlite"), "SQLite"); + assert_eq!(map_driver_ref("redshift", "redshift"), "Redshift"); + assert_eq!(map_driver_ref("custom.driver", "mariadb"), "MariaDB"); + assert_eq!(map_driver_ref(".mysql", ""), "MySQL"); + } + + fn source(driver_ref: &str, jdbc_url: &str) -> DataSource { + DataSource { + uuid: "uuid".to_owned(), + name: "source".to_owned(), + driver_ref: driver_ref.to_owned(), + jdbc_url: jdbc_url.to_owned(), + username: String::new(), + group_name: None, + ssh: None, + ssl: None, + } + } + + #[test] + fn a_url_that_asks_for_tls_keeps_it_when_the_settings_name_none() { + let verified = make_connection( + &source("postgresql", "jdbc:postgresql://db.example.com/app?sslmode=verify-full"), + &HashMap::new(), + ); + assert_eq!(verified.ssl.map(|ssl| ssl.mode), Some(ForeignSslMode::VerifyIdentity)); + let plain = make_connection( + &source("postgresql", "jdbc:postgresql://db.example.com/app"), + &HashMap::new(), + ); + assert_eq!(plain.ssl, None); + } + + #[test] + fn a_native_mongo_url_keeps_its_server_and_its_seed_list() { + let direct = make_connection( + &source("mongo", "mongodb://mongo.example.com:27018/shop"), + &HashMap::new(), + ); + assert_eq!( + (direct.host.as_str(), direct.port, direct.database.as_str()), + ("mongo.example.com", 27018, "shop") + ); + assert!(direct.additional_fields.is_empty()); + let seeded = make_connection( + &source("mongo", "mongodb+srv://cluster0.example.net/app"), + &HashMap::new(), + ); + assert_eq!(seeded.host, "cluster0.example.net"); + assert_eq!( + seeded.additional_fields.get(jdbc::MONGO_USE_SRV).map(String::as_str), + Some("true") + ); + } + + #[test] + fn a_mongo_source_is_known_by_its_url_or_its_driver() { + assert!(is_mongo(Some("unknown"), Some("mongodb://localhost:27017/x"))); + assert!(is_mongo(Some("mongo"), None)); + assert!(is_mongo(None, Some("jdbc:mongodb://h/db"))); + assert!(!is_mongo(Some("sqlite.xerial"), Some("jdbc:sqlite:/tmp/x.db"))); + } + + #[test] + fn key_authentication_follows_the_auth_type_then_the_key_path() { + assert!(uses_key_authentication(Some("open_ssh"), "")); + assert!(!uses_key_authentication(Some("PASSWORD"), "/k")); + assert!(uses_key_authentication(None, "/k")); + assert!(!uses_key_authentication(None, "")); + } +} diff --git a/core/crates/foreign-import/src/apps/dbeaver/credentials.rs b/core/crates/foreign-import/src/apps/dbeaver/credentials.rs new file mode 100644 index 0000000000..97874bb8bc --- /dev/null +++ b/core/crates/foreign-import/src/apps/dbeaver/credentials.rs @@ -0,0 +1,165 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockDecryptMut, KeyIvInit}; +use secrecy::SecretString; +use serde_json::Value; + +use crate::ForeignCredentials; + +// DBeaver encrypts every project's credentials with this one key, on every platform. +const KEY: [u8; 16] = [ + 0xBA, 0xBB, 0x4A, 0x9F, 0x77, 0x4A, 0xB8, 0x53, 0xC9, 0x6C, 0x2D, 0x65, 0x3D, 0xFE, 0x54, 0x4A, +]; +const IV_LENGTH: usize = 16; + +type Decryptor = cbc::Decryptor; + +// Holds decrypted passwords, so it is never logged. +pub(super) struct Credentials { + entries: BTreeMap, +} + +impl Credentials { + pub(super) fn load(path: &Path) -> Self { + let entries = crate::files::read_capped(path) + .ok() + .and_then(|data| decrypt(&data)) + .and_then(|plain| serde_json::from_slice::>(&plain).ok()) + .map(|mut entries| { + entries.retain(|_, entry| entry.is_object()); + entries + }) + .unwrap_or_default(); + Self { entries } + } + + pub(super) fn username(&self, id: &str) -> Option<&str> { + self.entries.get(id)?.get("#connection")?.get("user")?.as_str() + } + + pub(super) fn secrets(&self, id: &str) -> Option { + let entry = self.entries.get(id)?; + let password = entry + .get("#connection") + .and_then(|block| block.get("password")) + .and_then(Value::as_str); + let ssh_password = entry + .get("ssh_tunnel") + .and_then(|block| block.get("password")) + .and_then(Value::as_str); + if password.is_none() && ssh_password.is_none() { + return None; + } + Some(ForeignCredentials { + password: password.map(secret), + ssh_password: ssh_password.map(secret), + key_passphrase: None, + }) + } +} + +fn secret(value: &str) -> SecretString { + SecretString::from(value.to_owned()) +} + +// The file is a 16-byte IV followed by AES-128-CBC with PKCS#7 padding. +pub(super) fn decrypt(data: &[u8]) -> Option> { + if data.len() <= IV_LENGTH { + return None; + } + let (iv, ciphertext) = data.split_at(IV_LENGTH); + let decryptor = Decryptor::new_from_slices(&KEY, iv).ok()?; + match decryptor.decrypt_padded_vec_mut::(ciphertext) { + Ok(plain) => Some(plain), + Err(_) => { + tracing::warn!("could not decrypt a DBeaver credentials file"); + None + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use aes::cipher::BlockEncryptMut; + use secrecy::ExposeSecret; + + use super::*; + + fn encrypt(plain: &[u8], iv: [u8; 16]) -> Vec { + let ciphertext = + cbc::Encryptor::::new(&KEY.into(), &iv.into()).encrypt_padded_vec_mut::(plain); + [iv.as_slice(), &ciphertext].concat() + } + + #[test] + fn a_file_encrypted_with_the_fixed_key_decrypts() { + let file = encrypt(br#"{"a":1}"#, [7; 16]); + assert_eq!(decrypt(&file).as_deref(), Some(br#"{"a":1}"#.as_slice())); + } + + #[test] + fn a_short_truncated_or_tampered_file_decrypts_to_nothing() { + let file = encrypt(b"0123456789abcdef0123", [1; 16]); + assert_eq!(decrypt(&file[..16]), None); + assert_eq!(decrypt(&file[..file.len() - 1]), None); + let mut tampered = file.clone(); + if let Some(last) = tampered.last_mut() { + *last ^= 0xFF; + } + assert_eq!(decrypt(&tampered), None); + } + + #[test] + fn every_fixture_credentials_file_matches_its_plain_copy() { + let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dbeaver"); + let pairs = [ + "macos/Library/DBeaverData/workspace6/General/.dbeaver/credentials-config", + "linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config", + "linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config-2", + "windows/AppData/Roaming/DBeaverData/workspace6/General/.dbeaver/credentials-config", + ]; + for stem in pairs { + let encrypted = fs::read(fixtures.join(format!("{stem}.json"))).expect("an encrypted file"); + let plain = fs::read(fixtures.join(format!("{stem}.plain.json"))).expect("a plain copy"); + assert_eq!(decrypt(&encrypted), Some(plain), "{stem}"); + } + } + + #[test] + fn the_username_and_passwords_come_from_their_own_blocks() { + let directory = tempfile::tempdir().expect("a temp dir"); + let path = directory.path().join("credentials-config.json"); + let plain = br##"{ + "both": {"#connection": {"user": "u", "password": "p"}, "ssh_tunnel": {"password": "t"}}, + "user-only": {"#connection": {"user": "u"}}, + "empty-password": {"#connection": {"password": ""}}, + "not-an-object": "x" + }"##; + fs::write(&path, encrypt(plain, [3; 16])).expect("a file"); + let credentials = Credentials::load(&path); + + let both = credentials.secrets("both").expect("secrets"); + assert_eq!(both.password.as_ref().map(|s| s.expose_secret()), Some("p")); + assert_eq!(both.ssh_password.as_ref().map(|s| s.expose_secret()), Some("t")); + assert_eq!(credentials.username("both"), Some("u")); + assert!(credentials.secrets("user-only").is_none()); + assert_eq!(credentials.username("user-only"), Some("u")); + let empty = credentials.secrets("empty-password").expect("secrets"); + assert_eq!(empty.password.as_ref().map(|s| s.expose_secret()), Some("")); + assert!(credentials.secrets("not-an-object").is_none()); + assert!(credentials.secrets("missing").is_none()); + } + + #[test] + fn a_missing_or_undecryptable_file_reads_as_empty() { + let directory = tempfile::tempdir().expect("a temp dir"); + let path = directory.path().join("credentials-config.json"); + assert!(Credentials::load(&path).entries.is_empty()); + fs::write(&path, br##"{"plain":{"#connection":{"password":"p"}}}"##).expect("a file"); + assert!(Credentials::load(&path).entries.is_empty()); + } +} diff --git a/core/crates/foreign-import/src/apps/dbeaver/locations.rs b/core/crates/foreign-import/src/apps/dbeaver/locations.rs new file mode 100644 index 0000000000..9c7169fc77 --- /dev/null +++ b/core/crates/foreign-import/src/apps/dbeaver/locations.rs @@ -0,0 +1,300 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::{ForeignAppDirs, ForeignPlatform}; + +const DATA_FOLDER: &str = "DBeaverData"; +// Older Linux builds used this name, and DBeaver still reads it when `DBeaverData` is missing. +const HIDDEN_DATA_FOLDER: &str = ".DBeaverData"; +const FLATPAK_ID: &str = "io.dbeaver.DBeaverCommunity"; +const SNAP_NAME: &str = "dbeaver-ce"; +const WORKSPACE_PREFIX: &str = "workspace"; +const METADATA_FOLDER: &str = ".dbeaver"; +const CONFIG_PREFIX: &str = "data-sources"; +const CREDENTIALS_PREFIX: &str = "credentials-config"; +const JSON_EXTENSION: &str = ".json"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Project { + pub(super) path: PathBuf, + pub(super) configs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ConfigFile { + pub(super) name: String, + pub(super) path: PathBuf, + // DBeaver pairs `data-sources.json` with `credentials-config.json`. + pub(super) credentials_path: PathBuf, + pub(super) is_default: bool, +} + +// The Flatpak comes first to win a tie: it copies the native folder, times included, when it moves. +pub(super) fn data_roots(dirs: &ForeignAppDirs) -> Vec { + match dirs.platform { + ForeignPlatform::MacOs => vec![dirs.home.join("Library").join(DATA_FOLDER)], + ForeignPlatform::Windows => vec![dirs.config.join(DATA_FOLDER)], + ForeignPlatform::Linux => { + let local_share = dirs.home.join(".local").join("share"); + let mut roots = vec![ + dirs.home + .join(".var") + .join("app") + .join(FLATPAK_ID) + .join("data") + .join(DATA_FOLDER), + dirs.data.join(DATA_FOLDER), + ]; + // DBeaver reads XDG_DATA_HOME only as a Java property, so a native install keeps `~/.local/share`. + if dirs.data != local_share { + roots.push(local_share.join(DATA_FOLDER)); + } + roots.push(local_share.join(HIDDEN_DATA_FOLDER)); + roots.push( + dirs.home + .join("snap") + .join(SNAP_NAME) + .join("current") + .join(".local") + .join("share") + .join(DATA_FOLDER), + ); + roots + } + } +} + +// Moving between packages leaves two roots behind; the one DBeaver wrote last is the live one. +pub(super) fn find_project(roots: &[PathBuf]) -> Option { + let mut chosen: Option<(Project, Option)> = None; + for root in roots { + let Some(project) = project_in(root) else { + continue; + }; + let modified = project.last_modified(); + if chosen.as_ref().is_none_or(|(_, newest)| modified > *newest) { + chosen = Some((project, modified)); + } + } + chosen.map(|(project, _)| project) +} + +fn project_in(root: &Path) -> Option { + let mut workspaces: Vec<(String, PathBuf)> = children(root) + .into_iter() + .filter(|(name, _)| name.starts_with(WORKSPACE_PREFIX)) + .collect(); + // A later `workspace7` sorts ahead of `workspace6`. + workspaces.sort_by(|left, right| right.0.cmp(&left.0)); + workspaces.into_iter().find_map(|(_, workspace)| { + let mut projects = children(&workspace); + projects.sort_by(|left, right| left.0.cmp(&right.0)); + projects.into_iter().find_map(|(_, path)| Project::at(path)) + }) +} + +impl Project { + fn at(path: PathBuf) -> Option { + let configs = config_files(&path.join(METADATA_FOLDER)); + if configs.is_empty() { + None + } else { + Some(Self { path, configs }) + } + } + + fn last_modified(&self) -> Option { + self.configs + .iter() + .filter_map(|config| fs::metadata(&config.path).and_then(|metadata| metadata.modified()).ok()) + .max() + } +} + +fn config_files(metadata_folder: &Path) -> Vec { + let mut configs: Vec = children(metadata_folder) + .into_iter() + .filter_map(|(name, path)| { + let sub_id = name.strip_prefix(CONFIG_PREFIX)?.strip_suffix(JSON_EXTENSION)?; + if !fs::metadata(&path).is_ok_and(|metadata| metadata.is_file()) { + return None; + } + let credentials_path = metadata_folder.join(format!("{CREDENTIALS_PREFIX}{sub_id}{JSON_EXTENSION}")); + let is_default = sub_id.is_empty(); + Some(ConfigFile { + name, + path, + credentials_path, + is_default, + }) + }) + .collect(); + configs.sort_by(|left, right| (!left.is_default, &left.name).cmp(&(!right.is_default, &right.name))); + configs +} + +fn children(directory: &Path) -> Vec<(String, PathBuf)> { + let Ok(entries) = fs::read_dir(directory) else { + return Vec::new(); + }; + entries + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok().map(|name| (name, entry.path()))) + .collect() +} + +#[cfg(test)] +mod tests { + use std::fs::File; + use std::time::Duration; + + use super::*; + + fn write(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("a folder"); + } + fs::write(path, contents).expect("a file"); + } + + fn touch(path: &Path, modified: SystemTime) { + File::options() + .write(true) + .open(path) + .and_then(|file| file.set_modified(modified)) + .expect("a modification time"); + } + + fn names(project: &Project) -> Vec<&str> { + project.configs.iter().map(|config| config.name.as_str()).collect() + } + + #[test] + fn the_newest_workspace_name_wins_and_other_folders_are_ignored() { + let root = tempfile::tempdir().expect("a temp dir"); + write(&root.path().join("workspace5/General/.dbeaver/data-sources.json"), "{}"); + write(&root.path().join("workspace6/Second/.dbeaver/data-sources.json"), "{}"); + write(&root.path().join("workspace6/First/.dbeaver/data-sources.json"), "{}"); + write( + &root.path().join("workspace6/Empty/.dbeaver/project-metadata.json"), + "{}", + ); + write(&root.path().join("zz-backup/General/.dbeaver/data-sources.json"), "{}"); + let project = find_project(&[root.path().to_path_buf()]).expect("a project"); + assert_eq!(project.path, root.path().join("workspace6/First")); + } + + #[test] + fn a_workspace_without_a_data_file_falls_through_to_an_older_one() { + let root = tempfile::tempdir().expect("a temp dir"); + write( + &root.path().join("workspace6/General/.dbeaver/project-metadata.json"), + "{}", + ); + write(&root.path().join("workspace5/General/.dbeaver/data-sources.json"), "{}"); + let project = find_project(&[root.path().to_path_buf()]).expect("a project"); + assert_eq!(project.path, root.path().join("workspace5/General")); + } + + #[test] + fn every_data_sources_file_is_listed_default_first_with_its_own_credentials_file() { + let root = tempfile::tempdir().expect("a temp dir"); + let metadata = root.path().join("workspace6/General/.dbeaver"); + write(&metadata.join("data-sources-b.json"), "{}"); + write(&metadata.join("data-sources.json"), "{}"); + write(&metadata.join("data-sources-a.json"), "{}"); + write(&metadata.join("data-sources.json.bak"), "{}"); + write(&metadata.join("credentials-config.json"), ""); + fs::create_dir_all(metadata.join("data-sources-folder.json")).expect("a folder"); + let project = find_project(&[root.path().to_path_buf()]).expect("a project"); + assert_eq!( + names(&project), + ["data-sources.json", "data-sources-a.json", "data-sources-b.json"] + ); + assert!(project.configs[0].is_default); + assert!(!project.configs[1].is_default); + assert_eq!( + project.configs[0].credentials_path, + metadata.join("credentials-config.json") + ); + assert_eq!( + project.configs[1].credentials_path, + metadata.join("credentials-config-a.json") + ); + } + + #[test] + fn a_project_with_only_an_extra_data_sources_file_is_found() { + let root = tempfile::tempdir().expect("a temp dir"); + write( + &root.path().join("workspace6/General/.dbeaver/data-sources-team.json"), + "{}", + ); + let project = find_project(&[root.path().to_path_buf()]).expect("a project"); + assert_eq!(names(&project), ["data-sources-team.json"]); + } + + #[test] + fn the_root_whose_data_file_changed_last_wins_and_a_tie_keeps_the_first() { + let first = tempfile::tempdir().expect("a temp dir"); + let second = tempfile::tempdir().expect("a temp dir"); + let first_file = first.path().join("workspace6/General/.dbeaver/data-sources.json"); + let second_file = second.path().join("workspace6/General/.dbeaver/data-sources.json"); + write(&first_file, "{}"); + write(&second_file, "{}"); + let roots = [first.path().to_path_buf(), second.path().to_path_buf()]; + let earlier = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); + let later = earlier + Duration::from_secs(60); + + touch(&first_file, earlier); + touch(&second_file, later); + assert_eq!( + find_project(&roots).map(|p| p.path), + Some(second.path().join("workspace6/General")) + ); + + touch(&first_file, later); + assert_eq!( + find_project(&roots).map(|p| p.path), + Some(first.path().join("workspace6/General")) + ); + } + + #[test] + fn linux_searches_the_flatpak_the_data_folder_the_old_names_and_the_snap() { + let home = Path::new("/home/me"); + let mut dirs = ForeignAppDirs::conventional(ForeignPlatform::Linux, home); + assert_eq!( + data_roots(&dirs), + [ + home.join(".var/app/io.dbeaver.DBeaverCommunity/data/DBeaverData"), + home.join(".local/share/DBeaverData"), + home.join(".local/share/.DBeaverData"), + home.join("snap/dbeaver-ce/current/.local/share/DBeaverData"), + ] + ); + dirs.data = PathBuf::from("/data/me"); + assert_eq!( + data_roots(&dirs)[1..3], + [ + PathBuf::from("/data/me/DBeaverData"), + home.join(".local/share/DBeaverData") + ] + ); + } + + #[test] + fn macos_and_windows_have_one_root_each() { + let home = Path::new("/Users/me"); + assert_eq!( + data_roots(&ForeignAppDirs::conventional(ForeignPlatform::MacOs, home)), + [home.join("Library/DBeaverData")] + ); + let home = Path::new("C:/Users/me"); + assert_eq!( + data_roots(&ForeignAppDirs::conventional(ForeignPlatform::Windows, home)), + [home.join("AppData").join("Roaming").join("DBeaverData")] + ); + } +} diff --git a/core/crates/foreign-import/src/apps/dbeaver/mod.rs b/core/crates/foreign-import/src/apps/dbeaver/mod.rs new file mode 100644 index 0000000000..a77f279a97 --- /dev/null +++ b/core/crates/foreign-import/src/apps/dbeaver/mod.rs @@ -0,0 +1,515 @@ +mod credentials; +mod locations; +mod scripts; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde_json::Value; +use tokio_util::sync::CancellationToken; + +use self::credentials::Credentials; +use self::locations::{ConfigFile, Project}; +use crate::app::{AppReader, check_cancel}; +use crate::apps::{jdbc, resolve_key_path}; +use crate::collect::{PasswordOutcome, assemble}; +use crate::{ + CollectedImport, ForeignApp, ForeignAppDirs, ForeignColor, ForeignConnection, ForeignConnectionRecord, + ForeignImportError, ForeignSsh, ForeignSshAuth, ForeignSsl, ForeignSslMode, ImportRequest, Inventory, engine, +}; + +pub(crate) struct DBeaver { + data_roots: Vec, +} + +impl DBeaver { + pub(crate) fn new(data_roots: Vec) -> Self { + Self { data_roots } + } + + pub(crate) fn locate(dirs: &ForeignAppDirs) -> Self { + Self::new(locations::data_roots(dirs)) + } + + fn project(&self) -> Option { + locations::find_project(&self.data_roots) + } +} + +impl AppReader for DBeaver { + fn has_data(&self) -> bool { + self.project().is_some() + } + + fn inventory(&self) -> Inventory { + let Some(project) = self.project() else { + return Inventory::default(); + }; + Inventory { + connections: project.configs.iter().map(connection_count).sum(), + saved_queries: scripts::count(&project.path), + } + } + + fn collect( + &self, + request: &ImportRequest, + cancel: &CancellationToken, + ) -> Result { + let project = self.project().ok_or(ForeignImportError::FileNotFound { + app: ForeignApp::DBeaver, + })?; + let sources = read_data_sources(&project)?; + let folders = folder_descriptions(&sources); + + let mut records = Vec::new(); + for source in &sources { + for (id, connection) in &source.connections { + check_cancel(cancel)?; + records.push(ForeignConnectionRecord { + source_id: Some(id.clone()), + settings: settings(id, connection, source.credentials.username(id)), + group_path: group_path(connection.get("folder").and_then(Value::as_str), &folders), + credentials: request + .include_passwords + .then(|| source.credentials.secrets(id)) + .flatten(), + }); + } + } + // JSON objects have no order once parsed, so the review list is sorted. + records.sort_by(|left, right| sort_key(left).cmp(&sort_key(right))); + + let saved_queries = if request.include_saved_queries { + scripts::saved_queries(&project.path, request.saved_query_byte_limit, cancel)? + } else { + Vec::new() + }; + let passwords = if request.include_passwords { + PasswordOutcome::Read + } else { + PasswordOutcome::NotRequested + }; + assemble(ForeignApp::DBeaver, records, saved_queries, passwords) + } +} + +fn sort_key(record: &ForeignConnectionRecord) -> (&str, &str) { + (record.settings.name.as_str(), record.source_id.as_deref().unwrap_or("")) +} + +struct DataSources { + connections: BTreeMap, + folders: BTreeMap, + credentials: Credentials, +} + +fn read_data_sources(project: &Project) -> Result, ForeignImportError> { + let mut sources = Vec::with_capacity(project.configs.len()); + for config in &project.configs { + match read_config(config) { + Ok(source) => sources.push(source), + // A broken extra file leaves the main file's connections importable. + Err(error) if !config.is_default => { + tracing::warn!(code = error.code(), file = %config.name, "skipped a DBeaver data sources file"); + } + Err(error) => return Err(error), + } + } + Ok(sources) +} + +fn read_config(config: &ConfigFile) -> Result { + let mut json = crate::files::read_capped(&config.path) + .ok() + .and_then(|data| json_object(&data)) + .ok_or_else(|| ForeignImportError::parse(format!("Could not parse {}", config.name)))?; + let connections = json + .remove("connections") + .and_then(objects) + .ok_or_else(|| ForeignImportError::unsupported(format!("Missing connections key in {}", config.name)))?; + let folders = json.remove("folders").and_then(objects).unwrap_or_default(); + Ok(DataSources { + connections, + folders, + credentials: Credentials::load(&config.credentials_path), + }) +} + +fn connection_count(config: &ConfigFile) -> usize { + crate::files::read_capped(&config.path) + .ok() + .and_then(|data| json_object(&data)) + .and_then(|mut json| json.remove("connections")) + .and_then(object) + .map_or(0, |connections| connections.len()) +} + +fn json_object(data: &[u8]) -> Option> { + let data = data.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(data); + serde_json::from_slice(data).ok() +} + +fn object(value: Value) -> Option> { + serde_json::from_value(value).ok() +} + +// As the macOS app casts it: one entry that is not an object voids the whole map. +fn objects(value: Value) -> Option> { + object(value).filter(|entries| entries.values().all(Value::is_object)) +} + +// A folder defined in one file can hold connections of another, as DBeaver keeps one folder tree. +fn folder_descriptions(sources: &[DataSources]) -> BTreeMap { + let mut descriptions = BTreeMap::new(); + for source in sources { + for (path, folder) in &source.folders { + if let Some(description) = folder.get("description").and_then(Value::as_str) + && !description.is_empty() + { + descriptions + .entry(path.clone()) + .or_insert_with(|| description.to_owned()); + } + } + } + descriptions +} + +// A folder key is its full path; a folder's description, when set, is the name DBeaver shows. +fn group_path(folder: Option<&str>, descriptions: &BTreeMap) -> Vec { + let Some(folder) = folder else { + return Vec::new(); + }; + let components: Vec<&str> = folder.split('/').filter(|component| !component.is_empty()).collect(); + (0..components.len()) + .map(|index| { + let key = components[..=index].join("/"); + match descriptions.get(&key) { + Some(description) if !description.is_empty() => description.clone(), + _ => components[index].to_owned(), + } + }) + .collect() +} + +static NOTHING: Value = Value::Null; + +fn settings(id: &str, connection: &Value, credential_username: Option<&str>) -> ForeignConnection { + let name = connection.get("name").and_then(Value::as_str).unwrap_or(id).to_owned(); + let engine = map_provider(connection.get("provider").and_then(Value::as_str).unwrap_or("")); + let config = connection.get("configuration").unwrap_or(&NOTHING); + let text = |key: &str| config.get(key).and_then(Value::as_str); + // A connection given as a URL keeps only what the URL names, never the URL itself, which can + // hold a password. The macOS port stored the whole URL as the database name. + let endpoint = text("url") + .filter(|_| text("host").is_none_or(str::is_empty)) + .and_then(|url| jdbc::parse(url, &jdbc::subprotocol(url))); + let host = text("host") + .filter(|host| !host.is_empty()) + .map(str::to_owned) + .or_else(|| endpoint.as_ref().map(|endpoint| endpoint.host.clone())) + .unwrap_or_else(|| "localhost".to_owned()); + let database = text("database") + .map(str::to_owned) + .or_else(|| endpoint.as_ref().map(|endpoint| endpoint.database.clone())) + .unwrap_or_default(); + let username = [ + credential_username, + text("user"), + endpoint.as_ref().and_then(|endpoint| endpoint.username.as_deref()), + ] + .into_iter() + .flatten() + .find(|username| !username.is_empty()) + .unwrap_or(""); + ForeignConnection { + name, + host, + port: port(config.get("port")) + .or_else(|| endpoint.as_ref().and_then(|endpoint| endpoint.port)) + .unwrap_or_else(|| engine::default_port(&engine)), + database, + username: username.to_owned(), + ssh: ssh(config), + // A URL-only connection keeps the TLS its URL asks for, which the macOS port dropped. + ssl: ssl(config).or_else(|| text("url").and_then(jdbc::url_ssl)), + color: color(config), + // Read whether or not the host came from the URL: a seed list and an auth database live only there. + additional_fields: text("url").map(jdbc::url_fields).unwrap_or_default(), + engine, + } +} + +fn map_provider(provider: &str) -> String { + let known = match provider.to_lowercase().as_str() { + "mysql" => "MySQL", + "postgresql" => "PostgreSQL", + "sqlite" => "SQLite", + "sqlserver" => "SQL Server", + "oracle" => "Oracle", + "mongo" | "mongodb" => "MongoDB", + "redis" => "Redis", + "clickhouse" => "ClickHouse", + "mariadb" => "MariaDB", + "cassandra" => "Cassandra", + _ => return engine::resolve(provider), + }; + known.to_owned() +} + +fn ssh(config: &Value) -> Option { + let tunnel = config + .get("handlers")? + .get("ssh_tunnel") + .filter(|tunnel| tunnel.is_object())?; + let properties = tunnel.get("properties").filter(|properties| properties.is_object()); + let property = |key: &str| properties.and_then(|properties| properties.get(key)); + let text = |key: &str| property(key).and_then(Value::as_str); + + let enabled = tunnel + .get("enabled") + .and_then(boolean) + .unwrap_or_else(|| property("host").is_some()); + if !enabled { + return None; + } + let auth_type = text("authType").unwrap_or("PASSWORD"); + let auth = match auth_type { + "PUBLIC_KEY" => ForeignSshAuth::PrivateKey, + "AGENT" => ForeignSshAuth::Agent, + _ => ForeignSshAuth::Password, + }; + let private_key_path = if auth == ForeignSshAuth::PrivateKey { + resolve_key_path(text("keyPath").unwrap_or("")) + } else { + String::new() + }; + Some(ForeignSsh { + host: text("host").unwrap_or("").to_owned(), + port: port(property("port")), + username: text("username").unwrap_or("").to_owned(), + auth, + private_key_path, + jump_hosts: Vec::new(), + }) +} + +fn ssl(config: &Value) -> Option { + let handler = config + .get("handlers")? + .get("ssl") + .filter(|handler| handler.is_object())?; + if !handler.get("enabled").and_then(boolean).unwrap_or(false) { + return None; + } + let text = |key: &str| { + handler + .get("properties") + .and_then(|properties| properties.get(key)) + .and_then(Value::as_str) + }; + let mode = match text("sslMode").unwrap_or("") { + "require" => ForeignSslMode::Required, + "verify-ca" => ForeignSslMode::VerifyCa, + "verify-full" => ForeignSslMode::VerifyIdentity, + _ => ForeignSslMode::Preferred, + }; + Some(ForeignSsl { + mode, + ca_certificate_path: text("caCertPath").map(str::to_owned), + client_certificate_path: text("clientCertPath").map(str::to_owned), + client_key_path: text("clientKeyPath").map(str::to_owned), + }) +} + +// DBeaver keeps any colour the user picks as `r,g,b`; only the clear hues map to a TablePro colour. +fn color(config: &Value) -> Option { + let text = config.get("color")?.as_str().filter(|text| !text.is_empty())?; + let components: Vec = text + .split(',') + .filter_map(|component| component.trim_matches([' ', '\t']).parse().ok()) + .collect(); + let [r, g, b, ..] = components[..] else { + return None; + }; + if r > 200 && g < 100 && b < 100 { + Some(ForeignColor::Red) + } else if r > 200 && g > 100 && g < 200 && b < 100 { + Some(ForeignColor::Orange) + } else if r > 200 && g > 200 && b < 100 { + Some(ForeignColor::Yellow) + } else if r < 100 && g > 150 && b < 100 { + Some(ForeignColor::Green) + } else if r < 100 && g < 100 && b > 200 { + Some(ForeignColor::Blue) + } else if r > 100 && g < 100 && b > 150 { + Some(ForeignColor::Purple) + } else { + None + } +} + +// A port is a number or a numeric string; one outside 0...65535 counts as missing. +fn port(value: Option<&Value>) -> Option { + let value = value?; + let number = integer(value).or_else(|| value.as_str().and_then(|text| text.parse::().ok()))?; + u16::try_from(number).ok() +} + +fn integer(value: &Value) -> Option { + if let Some(number) = value.as_i64() { + return Some(number); + } + let number = value.as_f64()?; + let whole = number.trunc(); + // A whole float such as `5432.0` reads as an integer, as it does for the macOS app. + (whole == number && whole.abs() <= 9_007_199_254_740_992.0).then_some(whole as i64) +} + +// The macOS app reads a JSON 0 or 1 as a flag too. +fn boolean(value: &Value) -> Option { + match value { + Value::Bool(flag) => Some(*flag), + Value::Number(_) => match integer(value) { + Some(0) => Some(false), + Some(1) => Some(true), + _ => None, + }, + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use serde_json::json; + + use super::*; + + #[test] + fn a_url_with_a_password_in_it_leaves_only_its_server_and_database() { + let connection = parsed( + r#"{"provider": "postgresql", "name": "cloud", + "configuration": {"url": "jdbc:postgresql://admin:hunter2@db.cloud.example:6543/app?password=hunter2&sslmode=require"}}"#, + ); + let settings = settings("cloud-id", &connection, None); + assert_eq!( + (settings.host.as_str(), settings.port, settings.database.as_str()), + ("db.cloud.example", 6543, "app") + ); + assert!(!format!("{settings:?}").contains("hunter2")); + assert_eq!(settings.username, "admin"); + assert_eq!(settings.ssl.map(|ssl| ssl.mode), Some(ForeignSslMode::Required)); + } + + #[test] + fn a_mongo_seed_list_url_keeps_its_seed_list_and_its_tls() { + let connection = parsed( + r#"{"provider": "mongodb", "name": "atlas", + "configuration": {"url": "mongodb+srv://cluster0.example.net/app"}}"#, + ); + let settings = settings("atlas-id", &connection, None); + assert_eq!(settings.host, "cluster0.example.net"); + assert_eq!( + settings.additional_fields.get("mongoUseSrv").map(String::as_str), + Some("true") + ); + assert_eq!(settings.ssl.map(|ssl| ssl.mode), Some(ForeignSslMode::VerifyIdentity)); + } + + #[test] + fn a_mongo_url_beside_a_saved_host_still_gives_its_auth_database() { + let connection = parsed( + r#"{"provider": "mongodb", "name": "mongo", + "configuration": {"host": "mongo.example.com", "port": "27017", "database": "shop", + "url": "mongodb://mongo.example.com:27017/shop?authSource=admin"}}"#, + ); + let settings = settings("mongo-id", &connection, None); + assert_eq!(settings.host, "mongo.example.com"); + assert_eq!( + settings.additional_fields.get("mongoAuthSource").map(String::as_str), + Some("admin") + ); + } + + #[test] + fn a_url_that_asks_to_verify_the_server_keeps_it() { + let connection = parsed( + r#"{"provider": "postgresql", "name": "verified", + "configuration": {"url": "jdbc:postgresql://db.example.com/app?sslmode=verify-full"}}"#, + ); + let settings = settings("verified-id", &connection, None); + assert_eq!(settings.ssl.map(|ssl| ssl.mode), Some(ForeignSslMode::VerifyIdentity)); + } + + fn parsed(text: &str) -> Value { + serde_json::from_str(text).expect("JSON") + } + + #[test] + fn a_port_is_a_number_a_whole_float_or_a_numeric_string_in_range() { + assert_eq!(port(Some(&json!(5433))), Some(5433)); + assert_eq!(port(Some(&json!("5433"))), Some(5433)); + assert_eq!(port(Some(&json!(5433.0))), Some(5433)); + assert_eq!(port(Some(&json!(5433.5))), None); + assert_eq!(port(Some(&json!(" 5433"))), None); + assert_eq!(port(Some(&json!(70_000))), None); + assert_eq!(port(Some(&json!(-1))), None); + assert_eq!(port(Some(&json!(true))), None); + assert_eq!(port(None), None); + } + + #[test] + fn a_flag_is_a_boolean_or_zero_or_one() { + assert_eq!(boolean(&json!(true)), Some(true)); + assert_eq!(boolean(&json!(1)), Some(true)); + assert_eq!(boolean(&json!(0)), Some(false)); + assert_eq!(boolean(&json!(2)), None); + assert_eq!(boolean(&json!("true")), None); + } + + #[test] + fn a_folder_path_takes_each_level_s_description_or_its_own_name() { + let descriptions = BTreeMap::from([ + ("team".to_owned(), "Team".to_owned()), + ("team/backend/db".to_owned(), String::new()), + ]); + assert_eq!( + group_path(Some("team/backend/db"), &descriptions), + ["Team", "backend", "db"] + ); + assert_eq!(group_path(Some("/team//x/"), &descriptions), ["Team", "x"]); + assert!(group_path(Some(""), &descriptions).is_empty()); + assert!(group_path(None, &descriptions).is_empty()); + } + + #[test] + fn a_colour_needs_three_numbers_and_skips_the_rest() { + let colour = |text: &str| color(&parsed(&format!(r#"{{"color": "{text}"}}"#))); + assert_eq!(colour("255,0,0"), Some(ForeignColor::Red)); + assert_eq!(colour("x,255,0,0"), Some(ForeignColor::Red)); + assert_eq!(colour("255,0"), None); + assert_eq!(colour(""), None); + assert_eq!(color(&parsed(r#"{"color": 255}"#)), None); + } + + #[test] + fn a_connections_value_with_a_non_object_entry_is_unsupported() { + let directory = tempfile::tempdir().expect("a temp dir"); + let path = directory.path().join("data-sources.json"); + fs::write(&path, r#"{"connections": {"a": {}, "b": "text"}}"#).expect("a file"); + let config = ConfigFile { + name: "data-sources.json".to_owned(), + path, + credentials_path: directory.path().join("credentials-config.json"), + is_default: true, + }; + assert!(matches!( + read_config(&config), + Err(ForeignImportError::UnsupportedFormat { .. }) + )); + assert_eq!(connection_count(&config), 2); + } +} diff --git a/core/crates/foreign-import/src/apps/dbeaver/scripts.rs b/core/crates/foreign-import/src/apps/dbeaver/scripts.rs new file mode 100644 index 0000000000..aa68b9e98d --- /dev/null +++ b/core/crates/foreign-import/src/apps/dbeaver/scripts.rs @@ -0,0 +1,105 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use serde_json::Value; +use tokio_util::sync::CancellationToken; + +use crate::app::check_cancel; +use crate::sql_file_tree::{self, SqlFileEntry}; +use crate::{ForeignImportError, ForeignSavedQuery}; + +const SCRIPTS_FOLDER: &str = "Scripts"; + +pub(super) fn saved_queries( + project: &Path, + limit: u64, + cancel: &CancellationToken, +) -> Result, ForeignImportError> { + let bindings = data_source_bindings(project); + let entries = script_entries(project, cancel); + check_cancel(cancel)?; + let mut queries = Vec::with_capacity(entries.len()); + for entry in entries { + check_cancel(cancel)?; + let Some(content) = sql_file_tree::content(&entry, limit) else { + continue; + }; + let name = entry.stem(); + // DBeaver keys a script's settings by its path from the project, always with `/`. + let resource_path = std::iter::once(SCRIPTS_FOLDER) + .chain(entry.folder_path.iter().map(String::as_str)) + .chain(std::iter::once(entry.file_name.as_str())) + .collect::>() + .join("/"); + queries.push(ForeignSavedQuery { + is_auto_named: is_auto_named(&name), + name, + content, + keyword: None, + folder_path: entry.folder_path, + source_connection_id: bindings.get(&resource_path).cloned(), + }); + } + Ok(queries) +} + +pub(super) fn count(project: &Path) -> usize { + script_entries(project, &CancellationToken::new()) + .iter() + .filter(|entry| sql_file_tree::is_countable(entry)) + .count() +} + +// DBeaver names a new script `Script`, then `Script-1`, `Script-2` and so on. +pub(super) fn is_auto_named(name: &str) -> bool { + let lowered = name.to_lowercase(); + if lowered == "script" { + return true; + } + lowered + .strip_prefix("script-") + .is_some_and(|number| !number.is_empty() && number.chars().all(|c| c.is_ascii_digit())) +} + +fn script_entries(project: &Path, cancel: &CancellationToken) -> Vec { + sql_file_tree::entries( + &project.join(SCRIPTS_FOLDER), + &|name| name.to_lowercase().ends_with(".sql"), + cancel, + ) +} + +fn data_source_bindings(project: &Path) -> BTreeMap { + let path = project.join(".dbeaver").join("project-metadata.json"); + let Some(resources) = crate::files::read_capped(path) + .ok() + .and_then(|data| serde_json::from_slice::>(&data).ok()) + .and_then(|mut metadata| metadata.remove("resources")) + .and_then(|resources| serde_json::from_value::>(resources).ok()) + else { + return BTreeMap::new(); + }; + resources + .into_iter() + .filter_map(|(path, properties)| { + let id = properties.get("default-datasource")?.as_str()?; + (!id.is_empty()).then(|| (path, id.to_owned())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn script_names_dbeaver_generates_are_recognized() { + assert!(is_auto_named("Script")); + assert!(is_auto_named("Script-12")); + assert!(is_auto_named("script-2")); + assert!(!is_auto_named("Script-")); + assert!(!is_auto_named("Script-2b")); + assert!(!is_auto_named("Scripts")); + assert!(!is_auto_named("Monthly report")); + } +} diff --git a/core/crates/foreign-import/src/apps/jdbc.rs b/core/crates/foreign-import/src/apps/jdbc.rs new file mode 100644 index 0000000000..1d330ae164 --- /dev/null +++ b/core/crates/foreign-import/src/apps/jdbc.rs @@ -0,0 +1,610 @@ +use std::collections::HashMap; + +use crate::{ForeignSsl, ForeignSslMode}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Endpoint { + pub host: String, + pub port: Option, + pub database: String, + // The user a URL names before `@`; its password never leaves the URL. + pub username: Option, +} + +// The subprotocol is what follows `jdbc:` up to the next `:` or `/`. +pub(crate) fn subprotocol(url: &str) -> String { + match strip_prefix_ignoring_case(url, "jdbc:") { + Some(rest) => rest + .chars() + .take_while(|character| !matches!(character, ':' | '/')) + .collect(), + None => String::new(), + } +} + +pub(crate) fn parse(url: &str, subprotocol: &str) -> Option { + // The MongoDB driver takes its own URL, which the app stores with no `jdbc:` before it. + if is_native_mongo(url) { + return parse_authority(url.trim()); + } + let body = strip_prefix_ignoring_case(url.trim(), "jdbc:")?; + match subprotocol.to_lowercase().as_str() { + "sqlserver" | "jtds" => parse_sql_server(body), + "oracle" => parse_oracle(body), + "sqlite" | "duckdb" | "h2" => Some(parse_file_path(body, subprotocol)), + "bigquery" => parse_big_query(body), + _ => parse_authority(body), + } +} + +pub(crate) fn is_native_mongo(url: &str) -> bool { + let url = url.trim(); + strip_prefix_ignoring_case(url, "mongodb://").is_some() || is_mongo_seed_list(url) +} + +// The macOS MongoDB driver's field for a DNS seed list. +pub(crate) const MONGO_USE_SRV: &str = "mongoUseSrv"; + +// The macOS MongoDB driver's field for the database it signs in against. +pub(crate) const MONGO_AUTH_SOURCE: &str = "mongoAuthSource"; + +// What a MongoDB URL says that the record keeps as an engine field: a seed list, and the +// database it signs in against. +pub(crate) fn url_fields(url: &str) -> std::collections::BTreeMap { + let mut fields = std::collections::BTreeMap::new(); + if is_mongo_seed_list(url) { + fields.insert(MONGO_USE_SRV.to_owned(), "true".to_owned()); + } + if is_native_mongo(url) || subprotocol(url).eq_ignore_ascii_case("mongodb") { + let source = url_query(url) + .split('&') + .filter_map(|pair| pair.split_once('=')) + .filter(|(key, _)| key.trim().eq_ignore_ascii_case("authSource")) + .map(|(_, value)| percent_decoded(value.trim())) + .next_back() + .filter(|source| !source.is_empty()); + if let Some(source) = source { + fields.insert(MONGO_AUTH_SOURCE.to_owned(), source); + } + } + fields +} + +fn url_query(url: &str) -> &str { + url.split_once('?').map_or("", |(_, query)| query) +} + +pub(crate) fn is_mongo_seed_list(url: &str) -> bool { + strip_prefix_ignoring_case(url.trim(), "mongodb+srv://").is_some() +} + +// The TLS a URL asks for, in the macOS SSL mode names, read the way the URL's own driver reads it: +// an `ssl=true` verifies the server for PostgreSQL, SQL Server and MongoDB but not for MySQL, whose +// driver checks the certificate only when told to. A seed-list MongoDB URL turns TLS on by itself. +pub(crate) fn url_ssl_mode(url: &str) -> Option<&'static str> { + let protocol = url_protocol(url); + let parameters = url_parameters(url); + let flag = |names: &[&str]| { + parameters + .iter() + .rev() + .find(|(key, _)| names.contains(&key.as_str())) + .map(|(_, value)| matches!(value.as_str(), "true" | "1" | "yes" | "required" | "require" | "strict")) + }; + let unverified = flag(&[ + "trustservercertificate", + "tlsallowinvalidcertificates", + "tlsinsecure", + "sslallowinvalidcertificates", + ]) == Some(true) + || (protocol == UrlProtocol::Mysql && flag(&["verifyservercertificate"]) != Some(true)); + let enabled = match parameters.iter().rev().find(|(key, _)| key == "sslmode") { + Some((_, mode)) => return named_mode(mode), + None => flag(&["ssl", "tls", "usessl", "requiressl", "encrypt"]), + }; + match (enabled, protocol) { + (Some(false), _) => Some("disabled"), + (None, UrlProtocol::MongoSeedList) | (Some(true), _) if !unverified => Some("verify-full"), + (None, UrlProtocol::MongoSeedList) | (Some(true), _) => Some("required"), + (None, _) => None, + } +} + +fn named_mode(mode: &str) -> Option<&'static str> { + match mode { + "disable" | "disabled" => Some("disabled"), + "allow" | "prefer" | "preferred" => Some("preferred"), + // MariaDB's `trust` encrypts without checking the certificate. + "require" | "required" | "trust" => Some("required"), + "verify-ca" | "verify_ca" => Some("verify-ca"), + "verify-full" | "verify_full" | "verify_identity" => Some("verify-full"), + _ => None, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UrlProtocol { + Mysql, + MongoSeedList, + Other, +} + +fn url_protocol(url: &str) -> UrlProtocol { + if is_mongo_seed_list(url) { + return UrlProtocol::MongoSeedList; + } + match subprotocol(url).to_lowercase().as_str() { + "mysql" | "mariadb" => UrlProtocol::Mysql, + _ => UrlProtocol::Other, + } +} + +// Lowercased keys and values after `?` (and SQL Server's `;` properties), in order. +fn url_parameters(url: &str) -> Vec<(String, String)> { + let Some((_, query)) = url.split_once('?').or_else(|| url.split_once(';')) else { + return Vec::new(); + }; + query + .split(['&', ';']) + .filter_map(|pair| pair.split_once('=')) + .map(|(key, value)| (key.trim().to_lowercase(), value.trim().to_lowercase())) + .collect() +} + +// The TLS a URL asks for, as a record's SSL settings; none for a URL that asks for none or turns it off. +pub(crate) fn url_ssl(url: &str) -> Option { + let mode = match url_ssl_mode(url)? { + "disabled" => return None, + "preferred" => ForeignSslMode::Preferred, + "required" => ForeignSslMode::Required, + "verify-ca" => ForeignSslMode::VerifyCa, + _ => ForeignSslMode::VerifyIdentity, + }; + Some(ForeignSsl { + mode, + ca_certificate_path: None, + client_certificate_path: None, + client_key_path: None, + }) +} + +// jdbc:://[user[:pass]@]host[:port][/database][?params] +fn parse_authority(body: &str) -> Option { + let (_, remainder) = body.split_once("://")?; + let (head, tail) = split_once(strip_query(remainder), '/'); + let authority = strip_user_info(head); + let first_host = authority.split(',').next().unwrap_or(authority); + let (host, port) = parse_host_port(first_host); + let username = head + .rsplit_once('@') + .map(|(user_info, _)| user_info.split(':').next().unwrap_or_default()) + .map(percent_decoded) + .filter(|user| !user.is_empty()); + // A database name escapes a space or a slash, and the drivers take the name as written. + let database = percent_decoded(tail.unwrap_or_default()); + endpoint(host, port, &database).map(|endpoint| Endpoint { username, ..endpoint }) +} + +// A user name in a URL escapes `@`, `:` and the like as `%40`; a malformed escape stays as written. +fn percent_decoded(text: &str) -> String { + let bytes = text.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + let escaped = (bytes[index] == b'%') + .then(|| text.get(index + 1..index + 3)) + .flatten() + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + match escaped { + Some(byte) => { + decoded.push(byte); + index += 3; + } + None => { + decoded.push(bytes[index]); + index += 1; + } + } + } + String::from_utf8_lossy(&decoded).into_owned() +} + +// jdbc:sqlserver://host[\instance][:port][;prop=value...] and jdbc:jtds:sqlserver://host:port/database +fn parse_sql_server(body: &str) -> Option { + let mut remainder = body; + if let Some(rest) = strip_prefix_ignoring_case(remainder, "jtds:") { + remainder = rest; + } + if let Some(rest) = strip_prefix_ignoring_case(remainder, "sqlserver:") { + remainder = rest; + } + let remainder = remainder.strip_prefix("//")?; + let (before_properties, properties) = split_once(remainder, ';'); + let properties = semicolon_properties(properties.unwrap_or_default()); + let (authority, database) = split_once(before_properties, '/'); + // `host\instance:port` names its port after the instance, which the macOS port dropped with it. + let (host, port) = match authority.split_once('\\') { + Some((host, instance)) => (host, instance.rsplit_once(':').and_then(|(_, port)| port.parse().ok())), + None => parse_host_port(authority), + }; + let mut database = database.unwrap_or_default().to_owned(); + if database.is_empty() { + database = properties + .get("databasename") + .or_else(|| properties.get("database")) + .cloned() + .unwrap_or_default(); + } + endpoint(host, port, &database) +} + +// jdbc:oracle:thin:@host:port:SID, @//host:port/SERVICE and @host:port/SERVICE +fn parse_oracle(body: &str) -> Option { + let (_, descriptor) = body.split_once('@')?; + let descriptor = strip_query(descriptor); + + if let Some(rest) = descriptor.strip_prefix("//") { + let (head, tail) = split_once(rest, '/'); + let (host, port) = parse_host_port(head); + return endpoint(host, port, tail.unwrap_or_default()); + } + + let parts: Vec<&str> = descriptor.split(':').collect(); + let (Some(&host), Some(&port_and_rest)) = (parts.first(), parts.get(1)) else { + return host_port_path(descriptor); + }; + if host.is_empty() { + return host_port_path(descriptor); + } + let (port, service_name) = split_once(port_and_rest, '/'); + let port = port.parse().ok(); + let database = service_name.unwrap_or_else(|| parts.get(2).copied().unwrap_or_default()); + Some(Endpoint { + host: host.to_owned(), + port, + database: database.to_owned(), + username: None, + }) +} + +fn host_port_path(descriptor: &str) -> Option { + let (head, tail) = split_once(descriptor, '/'); + let (host, port) = parse_host_port(head); + endpoint(host, port, tail.unwrap_or_default()) +} + +// jdbc:sqlite:/path/to/file.db, jdbc:duckdb:/path/to/file.duckdb +fn parse_file_path(body: &str, subprotocol: &str) -> Endpoint { + let prefix = format!("{subprotocol}:"); + let path = strip_prefix_ignoring_case(body, &prefix).unwrap_or(body); + Endpoint { + host: String::new(), + port: None, + database: strip_query(path).to_owned(), + username: None, + } +} + +// jdbc:bigquery://https://www.googleapis.com/bigquery/v2;ProjectId=my-project;... +fn parse_big_query(body: &str) -> Option { + // The macOS port looked for `//` before stripping `bigquery:`, so it never found an endpoint. + let remainder = strip_prefix_ignoring_case(body, "bigquery:") + .unwrap_or(body) + .strip_prefix("//")?; + let (head, properties) = split_once(remainder, ';'); + let properties = semicolon_properties(properties.unwrap_or_default()); + let project = properties + .get("projectid") + .or_else(|| properties.get("project")) + .cloned() + .unwrap_or_default(); + Some(Endpoint { + host: head.to_owned(), + port: None, + database: project, + username: None, + }) +} + +fn endpoint(host: &str, port: Option, database: &str) -> Option { + if host.is_empty() { + return None; + } + Some(Endpoint { + host: host.to_owned(), + port, + database: database.to_owned(), + username: None, + }) +} + +fn parse_host_port(authority: &str) -> (&str, Option) { + if let Some(bracketed) = authority.strip_prefix('[') { + let Some((host, after)) = bracketed.split_once(']') else { + return (authority, None); + }; + return (host, after.strip_prefix(':').and_then(|port| port.parse().ok())); + } + match authority.rsplit_once(':') { + Some((host, port)) => (host, port.parse().ok()), + None => (authority, None), + } +} + +fn strip_user_info(authority: &str) -> &str { + authority.rsplit_once('@').map_or(authority, |(_, rest)| rest) +} + +fn strip_query(value: &str) -> &str { + value.split_once('?').map_or(value, |(head, _)| head) +} + +fn split_once(value: &str, separator: char) -> (&str, Option<&str>) { + match value.split_once(separator) { + Some((head, tail)) => (head, Some(tail)), + None => (value, None), + } +} + +fn semicolon_properties(value: &str) -> HashMap { + value + .split(';') + .filter(|pair| !pair.is_empty()) + .filter_map(|pair| pair.split_once('=')) + .map(|(key, value)| (key.to_lowercase().trim().to_owned(), value.to_owned())) + .collect() +} + +fn strip_prefix_ignoring_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + let head = value.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix).then(|| &value[prefix.len()..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed(url: &str, subprotocol: &str) -> Endpoint { + parse(url, subprotocol).expect("an endpoint") + } + + #[test] + fn mysql_reads_host_port_and_database() { + let endpoint = parsed("jdbc:mysql://db.example.com:3307/shop", "mysql"); + assert_eq!(endpoint.host, "db.example.com"); + assert_eq!(endpoint.port, Some(3307)); + assert_eq!(endpoint.database, "shop"); + } + + #[test] + fn postgresql_drops_query_parameters() { + let endpoint = parsed( + "jdbc:postgresql://localhost:5432/app?sslmode=require&user=x", + "postgresql", + ); + assert_eq!(endpoint.host, "localhost"); + assert_eq!(endpoint.port, Some(5432)); + assert_eq!(endpoint.database, "app"); + } + + #[test] + fn mysql_without_a_port_has_no_port() { + let endpoint = parsed("jdbc:mysql://localhost/app", "mysql"); + assert_eq!(endpoint.host, "localhost"); + assert_eq!(endpoint.port, None); + assert_eq!(endpoint.database, "app"); + } + + #[test] + fn the_authority_drops_user_info() { + let endpoint = parsed("jdbc:mysql://root:pw@host:3306/db", "mysql"); + assert_eq!(endpoint.host, "host"); + assert_eq!(endpoint.port, Some(3306)); + } + + #[test] + fn an_ipv6_host_comes_out_of_its_brackets() { + let endpoint = parsed("jdbc:postgresql://[::1]:5432/db", "postgresql"); + assert_eq!(endpoint.host, "::1"); + assert_eq!(endpoint.port, Some(5432)); + assert_eq!(endpoint.database, "db"); + } + + #[test] + fn sql_server_reads_the_database_from_semicolon_properties() { + let endpoint = parsed( + "jdbc:sqlserver://sql.example.com:1433;databaseName=Sales;encrypt=true", + "sqlserver", + ); + assert_eq!(endpoint.host, "sql.example.com"); + assert_eq!(endpoint.port, Some(1433)); + assert_eq!(endpoint.database, "Sales"); + } + + #[test] + fn sql_server_drops_the_named_instance() { + let endpoint = parsed("jdbc:sqlserver://host\\SQLEXPRESS;databaseName=db", "sqlserver"); + assert_eq!(endpoint.host, "host"); + assert_eq!(endpoint.database, "db"); + } + + #[test] + fn jtds_reads_the_path_form() { + let endpoint = parsed("jdbc:jtds:sqlserver://host:1433/mydb", "jtds"); + assert_eq!(endpoint.host, "host"); + assert_eq!(endpoint.port, Some(1433)); + assert_eq!(endpoint.database, "mydb"); + } + + #[test] + fn oracle_reads_the_sid_form() { + let endpoint = parsed("jdbc:oracle:thin:@orahost:1521:ORCL", "oracle"); + assert_eq!(endpoint.host, "orahost"); + assert_eq!(endpoint.port, Some(1521)); + assert_eq!(endpoint.database, "ORCL"); + } + + #[test] + fn oracle_reads_the_service_name_form_with_slashes() { + let endpoint = parsed("jdbc:oracle:thin:@//orahost:1521/PRODSVC", "oracle"); + assert_eq!(endpoint.host, "orahost"); + assert_eq!(endpoint.port, Some(1521)); + assert_eq!(endpoint.database, "PRODSVC"); + } + + #[test] + fn oracle_reads_the_service_name_form_without_slashes() { + let endpoint = parsed("jdbc:oracle:thin:@orahost:1521/PRODSVC", "oracle"); + assert_eq!(endpoint.host, "orahost"); + assert_eq!(endpoint.port, Some(1521)); + assert_eq!(endpoint.database, "PRODSVC"); + } + + #[test] + fn sqlite_keeps_the_file_path_as_the_database() { + let endpoint = parsed("jdbc:sqlite:/Users/me/data/app.db", "sqlite"); + assert_eq!(endpoint.host, ""); + assert_eq!(endpoint.port, None); + assert_eq!(endpoint.database, "/Users/me/data/app.db"); + } + + #[test] + fn duckdb_keeps_the_file_path_as_the_database() { + assert_eq!( + parsed("jdbc:duckdb:/tmp/analytics.duckdb", "duckdb").database, + "/tmp/analytics.duckdb" + ); + } + + #[test] + fn clickhouse_reads_the_authority_form() { + let endpoint = parsed("jdbc:clickhouse://ch.example.com:8123/metrics", "clickhouse"); + assert_eq!(endpoint.host, "ch.example.com"); + assert_eq!(endpoint.port, Some(8123)); + assert_eq!(endpoint.database, "metrics"); + } + + #[test] + fn mongodb_drops_the_auth_source_query() { + let endpoint = parsed("jdbc:mongodb://mongo:27017/app?authSource=admin", "mongodb"); + assert_eq!(endpoint.host, "mongo"); + assert_eq!(endpoint.port, Some(27017)); + assert_eq!(endpoint.database, "app"); + } + + #[test] + fn a_url_without_jdbc_has_no_endpoint() { + assert_eq!(parse("mysql://x/y", "mysql"), None); + } + + #[test] + fn a_native_mongo_url_names_its_first_host_and_database() { + let endpoint = parse( + "mongodb://app:secret@mongo1.example:27018,mongo2.example/shop?replicaSet=rs0", + "", + ) + .expect("an endpoint"); + assert_eq!(endpoint.host, "mongo1.example"); + assert_eq!(endpoint.port, Some(27018)); + assert_eq!(endpoint.database, "shop"); + let seeded = parse("mongodb+srv://cluster0.example.net/app", "").expect("an endpoint"); + assert_eq!((seeded.host.as_str(), seeded.port), ("cluster0.example.net", None)); + assert!(is_mongo_seed_list("mongodb+srv://cluster0.example.net/app")); + } + + #[test] + fn the_tls_a_url_asks_for_is_read_as_its_own_driver_reads_it() { + assert_eq!( + url_ssl_mode("jdbc:postgresql://db/app?sslmode=verify-full"), + Some("verify-full") + ); + assert_eq!( + url_ssl_mode("jdbc:postgresql://db/app?ssl=true&sslmode=verify-ca"), + Some("verify-ca") + ); + assert_eq!(url_ssl_mode("jdbc:postgresql://db/app?ssl=true"), Some("verify-full")); + assert_eq!( + url_ssl_mode("jdbc:postgresql://db/app?sslmode=require"), + Some("required") + ); + assert_eq!(url_ssl_mode("jdbc:mysql://db/app?sslMode=REQUIRED"), Some("required")); + assert_eq!(url_ssl_mode("jdbc:mariadb://db/app?sslMode=trust"), Some("required")); + assert_eq!(url_ssl_mode("jdbc:mysql://db/app?useSSL=true"), Some("required")); + assert_eq!( + url_ssl_mode("jdbc:mysql://db/app?useSSL=true&verifyServerCertificate=true"), + Some("verify-full") + ); + assert_eq!(url_ssl_mode("jdbc:mysql://db/app?useSSL=false"), Some("disabled")); + assert_eq!( + url_ssl_mode("jdbc:sqlserver://db:1433;databaseName=app;encrypt=true"), + Some("verify-full") + ); + assert_eq!( + url_ssl_mode("jdbc:sqlserver://db:1433;encrypt=true;trustServerCertificate=true"), + Some("required") + ); + assert_eq!(url_ssl_mode("mongodb://db/app?tls=true"), Some("verify-full")); + assert_eq!(url_ssl_mode("mongodb+srv://cluster.example/app"), Some("verify-full")); + assert_eq!( + url_ssl_mode("mongodb+srv://cluster.example/app?tls=false"), + Some("disabled") + ); + assert_eq!(url_ssl_mode("jdbc:postgresql://db/app"), None); + assert_eq!(url_ssl_mode("mongodb://db/app"), None); + } + + #[test] + fn a_url_names_its_user_but_never_its_password() { + let endpoint = parse("mongodb://alice%40corp:s3cret@db.example/shop", "").expect("an endpoint"); + assert_eq!(endpoint.username.as_deref(), Some("alice@corp")); + assert_eq!(endpoint.host, "db.example"); + assert!(!format!("{endpoint:?}").contains("s3cret")); + assert_eq!( + parse("jdbc:postgresql://db/app", "postgresql") + .expect("an endpoint") + .username, + None + ); + } + + #[test] + fn an_escaped_database_name_is_decoded() { + let endpoint = parse("jdbc:postgresql://db/team%20db?ssl=true", "postgresql").expect("an endpoint"); + assert_eq!(endpoint.database, "team db"); + } + + #[test] + fn a_sql_server_instance_keeps_the_port_named_after_it() { + let endpoint = parse("jdbc:sqlserver://db\\SALES:1444;databaseName=app", "sqlserver").expect("an endpoint"); + assert_eq!( + (endpoint.host.as_str(), endpoint.port, endpoint.database.as_str()), + ("db", Some(1444), "app") + ); + let bare = parse("jdbc:sqlserver://db\\SALES;databaseName=app", "sqlserver").expect("an endpoint"); + assert_eq!((bare.host.as_str(), bare.port), ("db", None)); + } + + #[test] + fn a_mongo_url_keeps_its_seed_list_and_its_auth_database_as_fields() { + let fields = url_fields("mongodb+srv://cluster0.example.net/app?authSource=admin&retryWrites=true"); + assert_eq!(fields.get(MONGO_USE_SRV).map(String::as_str), Some("true")); + assert_eq!(fields.get(MONGO_AUTH_SOURCE).map(String::as_str), Some("admin")); + assert!(url_fields("jdbc:postgresql://db/app?authSource=admin").is_empty()); + } + + #[test] + fn a_big_query_url_names_the_api_host_and_the_project() { + let url = "jdbc:bigquery://https://www.googleapis.com/bigquery/v2;ProjectId=my-project;OAuthType=3"; + let endpoint = parse(url, "bigquery").expect("an endpoint"); + assert_eq!(endpoint.host, "https://www.googleapis.com/bigquery/v2"); + assert_eq!(endpoint.port, None); + assert_eq!(endpoint.database, "my-project"); + } + + #[test] + fn the_subprotocol_stops_at_the_next_colon_or_slash() { + assert_eq!(subprotocol("jdbc:mysql://h/db"), "mysql"); + assert_eq!(subprotocol("JDBC:oracle:thin:@h:1521:X"), "oracle"); + assert_eq!(subprotocol("mongodb://h/db"), ""); + } +} diff --git a/core/crates/foreign-import/src/apps/jetbrains/credential_store.rs b/core/crates/foreign-import/src/apps/jetbrains/credential_store.rs new file mode 100644 index 0000000000..7208221de9 --- /dev/null +++ b/core/crates/foreign-import/src/apps/jetbrains/credential_store.rs @@ -0,0 +1,495 @@ +// The IDE names a secret `IntelliJ Platform \u{2014} `: subsystem `DB` keyed by the data +// source uuid, `SshConfigPassword` and `SshConfigPassphrase` keyed by `: `. +// The system keychain holds it by default; the "In KeePass" setting writes `c.kdbx` instead, so the +// keychain is asked first and the KeePass file is the fallback. + +use std::collections::HashMap; +use std::io::ErrorKind; +use std::path::PathBuf; +use std::sync::Arc; + +use aes::Aes128; +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockDecryptMut, KeyIvInit}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use secrecy::{ExposeSecret, SecretString}; + +use super::kdbx::{self, KdbxEntry}; +use crate::{ForeignKeychain, ForeignPlatform, KeychainRead}; + +const BUILT_IN_KEY: &[u8; 16] = b"Proxy Config Sec"; +const BUILT_IN: &str = "BUILT_IN"; +const CRYPT_32: &str = "CRYPT_32"; + +#[derive(Debug, Clone)] +pub(crate) enum Lookup { + Found(SecretString), + NotFound, + // The user refused the keychain, or the KeePass main key is one this platform cannot open. + Stopped, +} + +enum MainKey { + Key(Vec), + Locked, + Unreadable, +} + +pub(crate) struct CredentialStore { + config_dir: PathBuf, + platform: ForeignPlatform, + keychain: Option>, + entries: Option>, + loaded: bool, + locked: bool, +} + +impl CredentialStore { + pub(crate) fn new( + config_dir: PathBuf, + platform: ForeignPlatform, + keychain: Option>, + ) -> Self { + Self { + config_dir, + platform, + keychain, + entries: None, + loaded: false, + locked: false, + } + } + + pub(crate) fn service_name(uuid: &str) -> String { + format!("IntelliJ Platform DB \u{2014} {uuid}") + } + + pub(crate) fn ssh_password_service_name(host: &str, port: u16, config_id: &str) -> String { + format!("IntelliJ Platform SshConfigPassword \u{2014} {host}:{port} {config_id}") + } + + pub(crate) fn ssh_passphrase_service_name(host: &str, port: u16, config_id: &str) -> String { + format!("IntelliJ Platform SshConfigPassphrase \u{2014} {host}:{port} {config_id}") + } + + pub(crate) fn password(&mut self, uuid: &str) -> Lookup { + self.secret(&Self::service_name(uuid)) + } + + pub(crate) fn ssh_password(&mut self, host: &str, port: u16, config_id: &str) -> Lookup { + self.secret(&Self::ssh_password_service_name(host, port, config_id)) + } + + pub(crate) fn ssh_key_passphrase(&mut self, host: &str, port: u16, config_id: &str) -> Lookup { + self.secret(&Self::ssh_passphrase_service_name(host, port, config_id)) + } + + pub(crate) fn kdbx_loaded(&self) -> bool { + self.entries.is_some() + } + + fn secret(&mut self, service: &str) -> Lookup { + if let Some(keychain) = &self.keychain { + match keychain.read_password(service, None) { + KeychainRead::Found(value) => return Lookup::Found(value), + KeychainRead::Denied => return Lookup::Stopped, + KeychainRead::NotFound => {} + } + } + let found = self + .load_entries() + .and_then(|entries| entries.get(service)) + .filter(|entry| !entry.password.expose_secret().is_empty()) + .map(|entry| entry.password.clone()); + match found { + Some(password) => Lookup::Found(password), + None if self.locked => Lookup::Stopped, + None => Lookup::NotFound, + } + } + + fn load_entries(&mut self) -> Option<&HashMap> { + if !self.loaded { + self.loaded = true; + self.entries = self.read_entries(); + } + self.entries.as_ref() + } + + fn read_entries(&mut self) -> Option> { + let path = self.config_dir.join("c.kdbx"); + if !path.exists() { + return None; + } + let file = crate::files::read_capped(&path).ok()?; + let main_key = self.load_main_key()?; + match kdbx::read(&file, &main_key) { + Ok(entries) => { + let mut by_title = HashMap::with_capacity(entries.len()); + for entry in entries { + by_title.entry(entry.title.clone()).or_insert(entry); + } + Some(by_title) + } + Err(error) => { + tracing::warn!(?error, "could not read the IDE's KeePass file"); + None + } + } + } + + fn load_main_key(&mut self) -> Option> { + let main_key = match crate::files::read_capped(self.config_dir.join("c.pwd")) { + Ok(data) => { + let (encryption, blob) = parse_main_key_file(std::str::from_utf8(&data).ok()?)?; + decrypt_main_key(&encryption, &blob) + } + // The IDE reads the older `pdb.pwd`, raw bytes rather than YAML, only when `c.pwd` is missing. + Err(error) if error.kind() == ErrorKind::NotFound => { + let blob = crate::files::read_capped(self.config_dir.join("pdb.pwd")).ok()?; + decrypt_main_key(legacy_encryption(self.platform), &blob) + } + Err(_) => return None, + }; + match main_key { + MainKey::Key(key) => Some(key), + MainKey::Locked => { + tracing::warn!("the IDE's KeePass main key is not under its built-in key"); + self.locked = true; + None + } + MainKey::Unreadable => None, + } + } +} + +fn legacy_encryption(platform: ForeignPlatform) -> &'static str { + match platform { + ForeignPlatform::Windows => CRYPT_32, + ForeignPlatform::MacOs | ForeignPlatform::Linux => BUILT_IN, + } +} + +// Every main key is decrypted here. CRYPT_32 is BUILT_IN wrapped in the Windows data protection API. +fn decrypt_main_key(encryption: &str, blob: &[u8]) -> MainKey { + if encryption != BUILT_IN { + return MainKey::Locked; + } + decrypt_built_in(blob).map_or(MainKey::Unreadable, MainKey::Key) +} + +fn decrypt_built_in(blob: &[u8]) -> Option> { + let iv_length = usize::try_from(u32::from_be_bytes(blob.get(..4)?.try_into().ok()?)).ok()?; + if iv_length != 16 || blob.len() <= 4 + iv_length { + return None; + } + let (iv, ciphertext) = blob.get(4..)?.split_at(iv_length); + cbc::Decryptor::::new_from_slices(BUILT_IN_KEY, iv) + .ok()? + .decrypt_padded_vec_mut::(ciphertext) + .ok() +} + +// `c.pwd` is YAML with `encryption:` and a `value: !!binary` that may continue on indented lines. +fn parse_main_key_file(text: &str) -> Option<(String, Vec)> { + let mut encryption = BUILT_IN.to_owned(); + let mut parts: Vec = Vec::new(); + let mut capturing = false; + for line in text.split(is_newline) { + if capturing { + if line.starts_with([' ', '\t']) { + parts.push(line.to_owned()); + continue; + } + capturing = false; + } + if let Some((_, rest)) = line.split_once("encryption:") { + rest.trim().clone_into(&mut encryption); + } else if let Some((_, rest)) = line.split_once("value:") { + let rest = rest.replace("!!binary", ""); + let rest = rest.trim(); + if matches!(rest, "" | "|" | "|-" | ">") { + capturing = true; + } else { + parts.push(rest.to_owned()); + } + } + } + let base64: String = parts + .concat() + .chars() + .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '/' | '=')) + .collect(); + let blob = STANDARD.decode(base64).ok()?; + Some((encryption, blob)) +} + +fn is_newline(character: char) -> bool { + matches!( + character, + '\n' | '\u{0B}' | '\u{0C}' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}' + ) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use std::path::Path; + use std::sync::Mutex; + + use super::super::test_kdbx::{self, KdbxOptions, TestEntry}; + use super::*; + + struct Keychain { + secrets: HashMap, + reads: Mutex)>>, + } + + impl ForeignKeychain for Keychain { + fn read_password(&self, service: &str, account: Option<&str>) -> KeychainRead { + self.reads + .lock() + .expect("a lock") + .push((service.to_owned(), account.map(str::to_owned))); + self.secrets.get(service).cloned().unwrap_or(KeychainRead::NotFound) + } + } + + fn keychain(secrets: Vec<(String, KeychainRead)>) -> Arc { + Arc::new(Keychain { + secrets: secrets.into_iter().collect(), + reads: Mutex::new(Vec::new()), + }) + } + + fn write_kdbx(dir: &Path, main_key: &[u8], title: &str, password: &str) { + let file = test_kdbx::make_kdbx( + main_key, + &[TestEntry::new(title, "u", password)], + KdbxOptions::default(), + ); + fs::write(dir.join("c.kdbx"), file).expect("c.kdbx"); + } + + fn store(dir: &Path, platform: ForeignPlatform) -> CredentialStore { + CredentialStore::new(dir.to_path_buf(), platform, None) + } + + fn found(lookup: Lookup) -> String { + match lookup { + Lookup::Found(value) => value.expose_secret().to_owned(), + other => panic!("expected a secret, got {other:?}"), + } + } + + #[test] + fn the_service_name_uses_the_em_dash_separator() { + assert_eq!( + CredentialStore::service_name("abc-123"), + "IntelliJ Platform DB \u{2014} abc-123" + ); + } + + #[test] + fn ssh_service_names_match_the_ides_keychain_format() { + assert_eq!( + CredentialStore::ssh_password_service_name("localhost", 22, "cfg"), + "IntelliJ Platform SshConfigPassword \u{2014} localhost:22 cfg" + ); + assert_eq!( + CredentialStore::ssh_passphrase_service_name("h", 2222, "cfg"), + "IntelliJ Platform SshConfigPassphrase \u{2014} h:2222 cfg" + ); + } + + #[test] + fn a_password_is_read_from_c_kdbx_with_the_c_pwd_main_key() { + let dir = tempfile::tempdir().expect("a temp dir"); + let main_key = test_kdbx::bytes("main key", 64); + write_kdbx( + dir.path(), + &main_key, + &CredentialStore::service_name("a1b2c3"), + "kdbx-secret", + ); + fs::write(dir.path().join("c.pwd"), test_kdbx::main_key_file(&main_key)).expect("c.pwd"); + let mut store = store(dir.path(), ForeignPlatform::MacOs); + assert_eq!(found(store.password("a1b2c3")), "kdbx-secret"); + assert!(store.kdbx_loaded()); + } + + #[test] + fn an_ssh_password_is_read_from_c_kdbx() { + let dir = tempfile::tempdir().expect("a temp dir"); + let main_key = test_kdbx::bytes("main key", 64); + let service = CredentialStore::ssh_password_service_name("h", 2222, "cfg"); + write_kdbx(dir.path(), &main_key, &service, "ssh-secret"); + fs::write(dir.path().join("c.pwd"), test_kdbx::main_key_file(&main_key)).expect("c.pwd"); + assert_eq!( + found(store(dir.path(), ForeignPlatform::Linux).ssh_password("h", 2222, "cfg")), + "ssh-secret" + ); + } + + #[test] + fn missing_files_find_nothing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut store = store(dir.path(), ForeignPlatform::MacOs); + assert!(matches!(store.password("missing"), Lookup::NotFound)); + assert!(!store.kdbx_loaded()); + } + + #[test] + fn a_main_key_under_a_master_password_stops_the_lookups() { + let dir = tempfile::tempdir().expect("a temp dir"); + write_kdbx(dir.path(), &test_kdbx::bytes("main key", 64), "x", "p"); + fs::write( + dir.path().join("c.pwd"), + "isAutoGenerated: false\nvalue: !!binary AAAA\nencryption: MASTER_KEY\n", + ) + .expect("c.pwd"); + assert!(matches!( + store(dir.path(), ForeignPlatform::MacOs).password("any"), + Lookup::Stopped + )); + } + + #[test] + fn a_crypt_32_main_key_stops_the_lookups() { + let dir = tempfile::tempdir().expect("a temp dir"); + let main_key = test_kdbx::bytes("main key", 64); + write_kdbx(dir.path(), &main_key, &CredentialStore::service_name("u"), "p"); + let blob = STANDARD.encode(test_kdbx::built_in_blob(&main_key)); + fs::write( + dir.path().join("c.pwd"), + format!("encryption: CRYPT_32\nisAutoGenerated: true\nvalue: !!binary {blob}\n"), + ) + .expect("c.pwd"); + assert!(matches!( + store(dir.path(), ForeignPlatform::Windows).password("u"), + Lookup::Stopped + )); + } + + #[test] + fn a_raw_pdb_pwd_is_read_when_c_pwd_is_missing() { + let dir = tempfile::tempdir().expect("a temp dir"); + let main_key = test_kdbx::bytes("legacy main key", 64); + write_kdbx( + dir.path(), + &main_key, + &CredentialStore::service_name("old"), + "legacy-secret", + ); + fs::write(dir.path().join("pdb.pwd"), test_kdbx::built_in_blob(&main_key)).expect("pdb.pwd"); + assert_eq!( + found(store(dir.path(), ForeignPlatform::Linux).password("old")), + "legacy-secret" + ); + } + + #[test] + fn pdb_pwd_is_ignored_while_c_pwd_exists() { + let dir = tempfile::tempdir().expect("a temp dir"); + let main_key = test_kdbx::bytes("legacy main key", 64); + write_kdbx( + dir.path(), + &main_key, + &CredentialStore::service_name("old"), + "legacy-secret", + ); + fs::write(dir.path().join("pdb.pwd"), test_kdbx::built_in_blob(&main_key)).expect("pdb.pwd"); + fs::write(dir.path().join("c.pwd"), "encryption: BUILT_IN\nvalue: !!binary AAAA\n").expect("c.pwd"); + let mut store = store(dir.path(), ForeignPlatform::Linux); + assert!(matches!(store.password("old"), Lookup::NotFound)); + assert!(!store.kdbx_loaded()); + } + + #[test] + fn a_pdb_pwd_on_windows_is_under_crypt_32_and_stops_the_lookups() { + let dir = tempfile::tempdir().expect("a temp dir"); + let main_key = test_kdbx::bytes("legacy main key", 64); + write_kdbx( + dir.path(), + &main_key, + &CredentialStore::service_name("old"), + "legacy-secret", + ); + fs::write(dir.path().join("pdb.pwd"), test_kdbx::built_in_blob(&main_key)).expect("pdb.pwd"); + assert!(matches!( + store(dir.path(), ForeignPlatform::Windows).password("old"), + Lookup::Stopped + )); + } + + #[test] + fn the_keychain_is_asked_first_by_service_name_alone() { + let dir = tempfile::tempdir().expect("a temp dir"); + let main_key = test_kdbx::bytes("main key", 64); + write_kdbx( + dir.path(), + &main_key, + &CredentialStore::service_name("both"), + "from-kdbx", + ); + fs::write(dir.path().join("c.pwd"), test_kdbx::main_key_file(&main_key)).expect("c.pwd"); + let keychain = keychain(vec![( + CredentialStore::service_name("both"), + KeychainRead::Found(SecretString::from("from-keychain")), + )]); + let mut store = CredentialStore::new(dir.path().to_path_buf(), ForeignPlatform::MacOs, Some(keychain.clone())); + assert_eq!(found(store.password("both")), "from-keychain"); + assert!(!store.kdbx_loaded()); + let reads = keychain.reads.lock().expect("a lock").clone(); + assert_eq!(reads, [(CredentialStore::service_name("both"), None)]); + } + + #[test] + fn c_kdbx_answers_what_the_keychain_does_not_hold() { + let dir = tempfile::tempdir().expect("a temp dir"); + let main_key = test_kdbx::bytes("main key", 64); + write_kdbx( + dir.path(), + &main_key, + &CredentialStore::service_name("kdbx"), + "from-kdbx", + ); + fs::write(dir.path().join("c.pwd"), test_kdbx::main_key_file(&main_key)).expect("c.pwd"); + let mut store = CredentialStore::new( + dir.path().to_path_buf(), + ForeignPlatform::MacOs, + Some(keychain(Vec::new())), + ); + assert_eq!(found(store.password("kdbx")), "from-kdbx"); + } + + #[test] + fn a_refused_keychain_stops_the_lookups() { + let dir = tempfile::tempdir().expect("a temp dir"); + let keychain = keychain(vec![(CredentialStore::service_name("x"), KeychainRead::Denied)]); + let mut store = CredentialStore::new(dir.path().to_path_buf(), ForeignPlatform::MacOs, Some(keychain)); + assert!(matches!(store.password("x"), Lookup::Stopped)); + } + + #[test] + fn a_main_key_file_value_can_continue_on_indented_lines() { + let blob = test_kdbx::built_in_blob(b"key"); + let encoded = STANDARD.encode(&blob); + let (head, tail) = encoded.split_at(10); + let text = + format!("encryption: BUILT_IN\nisAutoGenerated: true\nvalue: !!binary |-\n {head}\n\t{tail}\nnext: 1\n"); + let (encryption, parsed) = parse_main_key_file(&text).expect("a main key file"); + assert_eq!(encryption, "BUILT_IN"); + assert_eq!(parsed, blob); + assert_eq!(decrypt_built_in(&parsed).as_deref(), Some(b"key".as_slice())); + } + + #[test] + fn a_main_key_file_without_an_encryption_line_is_built_in() { + let (encryption, blob) = parse_main_key_file("value: !!binary AAAA").expect("a main key file"); + assert_eq!(encryption, "BUILT_IN"); + assert_eq!(blob, [0, 0, 0]); + } +} diff --git a/core/crates/foreign-import/src/apps/jetbrains/inner_stream.rs b/core/crates/foreign-import/src/apps/jetbrains/inner_stream.rs new file mode 100644 index 0000000000..320736f088 --- /dev/null +++ b/core/crates/foreign-import/src/apps/jetbrains/inner_stream.rs @@ -0,0 +1,118 @@ +use chacha20::ChaCha20; +use salsa20::Salsa20; +use salsa20::cipher::{KeyIvInit, StreamCipher}; +use sha2::{Digest, Sha256, Sha512}; + +const KEEPASS_SALSA20_NONCE: [u8; 8] = [0xe8, 0x30, 0x09, 0x4b, 0x97, 0x20, 0x5d, 0x2a]; + +// One keystream runs across every protected value of a file, in document order. +pub(crate) enum InnerStreamCipher { + Salsa20(Box), + ChaCha20(Box), +} + +impl InnerStreamCipher { + pub(crate) fn for_stream(id: u32, stream_key: &[u8]) -> Option { + match id { + 2 => Salsa20::new_from_slices(&Sha256::digest(stream_key), &KEEPASS_SALSA20_NONCE) + .ok() + .map(|cipher| Self::Salsa20(Box::new(cipher))), + 3 => { + let digest = Sha512::digest(stream_key); + Self::chacha20(&digest[..32], &digest[32..44]) + } + _ => { + tracing::warn!(id, "unsupported KDBX inner stream, protected values stay unread"); + None + } + } + } + + pub(crate) fn chacha20(key: &[u8], nonce: &[u8]) -> Option { + ChaCha20::new_from_slices(key, nonce) + .ok() + .map(|cipher| Self::ChaCha20(Box::new(cipher))) + } + + pub(crate) fn process(&mut self, data: &[u8]) -> Option> { + let mut output = data.to_vec(); + let applied = match self { + Self::Salsa20(cipher) => cipher.try_apply_keystream(&mut output), + Self::ChaCha20(cipher) => cipher.try_apply_keystream(&mut output), + }; + applied.ok().map(|()| output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keystream(cipher: &mut InnerStreamCipher, count: usize) -> Vec { + cipher.process(&vec![0; count]).expect("a keystream") + } + + #[test] + fn chacha20_matches_the_rfc_8439_keystream_block_0() { + let mut cipher = InnerStreamCipher::chacha20(&[0; 32], &[0; 12]).expect("a cipher"); + let expected = hex::decode(concat!( + "76b8e0ada0f13d90405d6ae55386bd28bdd219b8a08ded1aa836efcc8b770dc7", + "da41597c5157488d7724e03fb8d84a376a43b8f41518a11cc387b669b2ee6586" + )) + .expect("hex"); + assert_eq!(keystream(&mut cipher, 64), expected); + } + + #[test] + fn chacha20_matches_the_rfc_8439_keystream_block_1() { + let mut cipher = InnerStreamCipher::chacha20(&[0; 32], &[0; 12]).expect("a cipher"); + let expected = hex::decode(concat!( + "9f07e7be5551387a98ba977c732d080dcb0f29a048e3656912c6533e32ee7aed", + "29b721769ce64e43d57133b074d839d531ed1f28510afb45ace10a1f4b794d6f" + )) + .expect("hex"); + assert_eq!(keystream(&mut cipher, 128)[64..], expected); + } + + #[test] + fn processing_twice_with_the_same_key_gives_the_plaintext_back() { + let key: Vec = (0..32).collect(); + let nonce: Vec = (0..12).collect(); + let plaintext = b"the quick brown fox"; + let mut encrypt = InnerStreamCipher::chacha20(&key, &nonce).expect("a cipher"); + let ciphertext = encrypt.process(plaintext).expect("ciphertext"); + let mut decrypt = InnerStreamCipher::chacha20(&key, &nonce).expect("a cipher"); + assert_eq!(decrypt.process(&ciphertext).expect("plaintext"), plaintext); + } + + #[test] + fn the_keystream_continues_across_values_rather_than_restarting() { + let mut whole = InnerStreamCipher::for_stream(3, b"stream key").expect("a cipher"); + let mut split = InnerStreamCipher::for_stream(3, b"stream key").expect("a cipher"); + let all = keystream(&mut whole, 100); + let mut parts = keystream(&mut split, 30); + parts.extend(keystream(&mut split, 70)); + assert_eq!(all, parts); + } + + #[test] + fn salsa20_matches_the_estream_256_bit_set_1_vector_0() { + let mut key = [0; 32]; + key[0] = 0x80; + let mut cipher = Salsa20::new_from_slices(&key, &[0; 8]).expect("a cipher"); + let mut block = vec![0; 64]; + cipher.apply_keystream(&mut block); + let expected = hex::decode(concat!( + "e3be8fdd8beca2e3ea8ef9475b29a6e7003951e1097a5c38d23b7a5fad9f6844", + "b22c97559e2723c7cbbd3fe4fc8d9a0744652a83e72a9c461876af4d7ef1a117" + )) + .expect("hex"); + assert_eq!(block, expected); + } + + #[test] + fn an_unknown_stream_id_gives_no_cipher() { + assert!(InnerStreamCipher::for_stream(1, b"key").is_none()); + assert!(InnerStreamCipher::for_stream(0, b"key").is_none()); + } +} diff --git a/core/crates/foreign-import/src/apps/jetbrains/kdbx.rs b/core/crates/foreign-import/src/apps/jetbrains/kdbx.rs new file mode 100644 index 0000000000..720135cdb8 --- /dev/null +++ b/core/crates/foreign-import/src/apps/jetbrains/kdbx.rs @@ -0,0 +1,410 @@ +// The KDBX 3.1 file (`c.kdbx`) the IDE writes when it keeps secrets in KeePass: AES-KDF over the +// main key, an AES-256-CBC body of hashed blocks, optionally gzipped XML, and protected values +// under an inner stream cipher. + +use std::collections::HashMap; +use std::io::Read; + +use aes::Aes256; +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockDecryptMut, BlockEncrypt, KeyInit, KeyIvInit}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use flate2::read::GzDecoder; +use roxmltree::{Document, Node, NodeId}; +use secrecy::SecretString; +use sha2::{Digest, Sha256}; + +use super::inner_stream::InnerStreamCipher; +use super::xml; + +const SIGNATURE_1: u32 = 0x9AA2_D903; +const SIGNATURE_2: u32 = 0xB54B_FB67; +const MAXIMUM_VERSION: u32 = 0x0003_0000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KdbxError { + MalformedHeader, + UnsupportedVersion, + WrongKey, + CorruptedData, +} + +#[derive(Debug, Clone)] +pub(crate) struct KdbxEntry { + pub title: String, + pub password: SecretString, +} + +#[derive(Default)] +struct Header { + main_seed: Vec, + transform_seed: Vec, + transform_rounds: u64, + encryption_iv: Vec, + protected_stream_key: Vec, + stream_start_bytes: Vec, + inner_stream_id: u32, + compression: u32, +} + +pub(crate) fn read(file: &[u8], main_key: &[u8]) -> Result, KdbxError> { + let (header, payload_offset) = parse_header(file)?; + let final_key = derive_final_key(&header, main_key)?; + let payload = file.get(payload_offset..).unwrap_or_default(); + let plaintext = aes256_cbc_decrypt(payload, &final_key, &header.encryption_iv).ok_or(KdbxError::CorruptedData)?; + if plaintext.get(..32) != Some(header.stream_start_bytes.as_slice()) { + return Err(KdbxError::WrongKey); + } + let deframed = dehash_blocks(plaintext.get(32..).unwrap_or_default()).ok_or(KdbxError::CorruptedData)?; + let xml = if header.compression == 1 { + gunzip(&deframed).unwrap_or_default() + } else { + deframed + }; + if xml.is_empty() { + return Err(KdbxError::CorruptedData); + } + let cipher = InnerStreamCipher::for_stream(header.inner_stream_id, &header.protected_stream_key); + Ok(parse_entries(&xml, cipher)) +} + +fn parse_header(data: &[u8]) -> Result<(Header, usize), KdbxError> { + if data.len() <= 12 || le_u32(data, 0) != Some(SIGNATURE_1) || le_u32(data, 4) != Some(SIGNATURE_2) { + return Err(KdbxError::MalformedHeader); + } + let version = le_u32(data, 8).ok_or(KdbxError::MalformedHeader)?; + if version & 0xFFFF_0000 > MAXIMUM_VERSION { + return Err(KdbxError::UnsupportedVersion); + } + + let mut header = Header::default(); + let mut position = 12; + while position + 3 <= data.len() { + let field_type = data.get(position).copied().ok_or(KdbxError::MalformedHeader)?; + let length = usize::from(le_u16(data, position + 1).ok_or(KdbxError::MalformedHeader)?); + position += 3; + let field = data + .get(position..position + length) + .ok_or(KdbxError::MalformedHeader)?; + position += length; + // A numeric field shorter than its type is malformed, not read past its end. + let malformed = KdbxError::MalformedHeader; + match field_type { + 0 => return Ok((header, position)), + 3 => header.compression = le_u32(field, 0).ok_or(malformed)?, + 4 => header.main_seed = field.to_vec(), + 5 => header.transform_seed = field.to_vec(), + 6 => header.transform_rounds = le_u64(field).ok_or(malformed)?, + 7 => header.encryption_iv = field.to_vec(), + 8 => header.protected_stream_key = field.to_vec(), + 9 => header.stream_start_bytes = field.to_vec(), + 10 => header.inner_stream_id = le_u32(field, 0).ok_or(malformed)?, + _ => {} + } + } + Err(KdbxError::MalformedHeader) +} + +// Far above what the IDE writes, and a second or two of work at most, so a crafted file cannot +// hold the import's worker for good. +const MAX_TRANSFORM_ROUNDS: u64 = 50_000_000; + +fn derive_final_key(header: &Header, main_key: &[u8]) -> Result, KdbxError> { + if header.transform_rounds > MAX_TRANSFORM_ROUNDS { + return Err(KdbxError::MalformedHeader); + } + let composite = Sha256::digest(Sha256::digest(main_key)); + let transformed = + aes_kdf(&composite, &header.transform_seed, header.transform_rounds).ok_or(KdbxError::CorruptedData)?; + let mut hasher = Sha256::new(); + hasher.update(&header.main_seed); + hasher.update(Sha256::digest(transformed)); + Ok(hasher.finalize().to_vec()) +} + +pub(super) fn aes_kdf(input: &[u8], seed: &[u8], rounds: u64) -> Option<[u8; 32]> { + if input.len() != 32 || seed.len() != 32 { + return None; + } + let cipher = Aes256::new_from_slice(seed).ok()?; + let (first, second) = input.split_at(16); + let mut blocks = [ + aes::Block::clone_from_slice(first), + aes::Block::clone_from_slice(second), + ]; + for _ in 0..rounds { + cipher.encrypt_blocks(&mut blocks); + } + let mut output = [0; 32]; + output[..16].copy_from_slice(&blocks[0]); + output[16..].copy_from_slice(&blocks[1]); + Some(output) +} + +fn aes256_cbc_decrypt(ciphertext: &[u8], key: &[u8], iv: &[u8]) -> Option> { + if ciphertext.is_empty() || iv.len() != 16 { + return None; + } + cbc::Decryptor::::new_from_slices(key, iv) + .ok()? + .decrypt_padded_vec_mut::(ciphertext) + .ok() +} + +fn dehash_blocks(data: &[u8]) -> Option> { + let mut result = Vec::new(); + let mut position = 0; + while position + 40 <= data.len() { + let hash = data.get(position + 4..position + 36)?; + let size = usize::try_from(le_u32(data, position + 36)?).ok()?; + position += 40; + if size == 0 { + return Some(result); + } + let block = data.get(position..position.checked_add(size)?)?; + if Sha256::digest(block).as_slice() != hash { + return None; + } + result.extend_from_slice(block); + position += size; + } + Some(result) +} + +fn gunzip(data: &[u8]) -> Option> { + let mut inflated = Vec::new(); + // A compressed body has the same cap as a file, so a small file cannot inflate without end. + GzDecoder::new(data) + .take(crate::files::MAX_FILE_BYTES + 1) + .read_to_end(&mut inflated) + .ok()?; + if u64::try_from(inflated.len()).unwrap_or(u64::MAX) > crate::files::MAX_FILE_BYTES { + return None; + } + Some(inflated) +} + +fn parse_entries(data: &[u8], cipher: Option) -> Vec { + let Some(document) = xml::document(data) else { + return Vec::new(); + }; + let decrypted = cipher + .map(|cipher| decrypt_protected_values(&document, cipher)) + .unwrap_or_default(); + xml::elements(&document, "Entry") + .map(|entry| parse_entry(entry, &decrypted)) + .collect() +} + +fn is_protected(value: Node<'_, '_>) -> bool { + value + .attribute("Protected") + .unwrap_or_default() + .eq_ignore_ascii_case("true") +} + +// Every protected value takes the next bytes of one keystream, so they are read in document order. +fn decrypt_protected_values(document: &Document<'_>, mut cipher: InnerStreamCipher) -> HashMap { + let mut values = HashMap::new(); + for value in xml::elements(document, "Value") { + if !is_protected(value) { + continue; + } + let Ok(encrypted) = STANDARD.decode(xml::text(value).trim()) else { + continue; + }; + let Some(plain) = cipher.process(&encrypted) else { + continue; + }; + values.insert( + value.id(), + SecretString::from(String::from_utf8(plain).unwrap_or_default()), + ); + } + values +} + +fn parse_entry(entry: Node<'_, '_>, decrypted: &HashMap) -> KdbxEntry { + let mut title = String::new(); + let mut password = SecretString::from(""); + for string in entry + .children() + .filter(|child| child.is_element() && child.has_tag_name("String")) + { + let key = xml::child(string, "Key").map(xml::text).unwrap_or_default(); + let Some(value) = xml::child(string, "Value") else { + continue; + }; + match key.as_str() { + "Title" => title = xml::text(value), + "Password" => { + password = match decrypted.get(&value.id()) { + Some(plain) => plain.clone(), + // A protected value that did not decrypt is ciphertext, never a password. + None if is_protected(value) => SecretString::from(""), + None => SecretString::from(xml::text(value)), + }; + } + _ => {} + } + } + KdbxEntry { title, password } +} + +fn le_u16(data: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(data.get(at..at + 2)?.try_into().ok()?)) +} + +fn le_u32(data: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(data.get(at..at + 4)?.try_into().ok()?)) +} + +fn le_u64(data: &[u8]) -> Option { + Some(u64::from_le_bytes(data.get(..8)?.try_into().ok()?)) +} + +#[cfg(test)] +mod tests { + use secrecy::ExposeSecret; + + use super::super::test_kdbx::{self, InnerStream, KdbxOptions, TestEntry}; + use super::*; + + fn password_of<'a>(entries: &'a [KdbxEntry], title: &str) -> Option<&'a str> { + entries + .iter() + .find(|entry| entry.title == title) + .map(|entry| entry.password.expose_secret()) + } + + #[test] + fn an_entry_is_read_and_its_chacha20_protected_password_decrypted() { + let main_key = b"super-secret-main-key"; + let title = "IntelliJ Platform DB \u{2014} a1b2c3"; + let file = test_kdbx::make_kdbx( + main_key, + &[TestEntry::new(title, "dbuser", "p@ssw0rd!")], + KdbxOptions::default(), + ); + let entries = read(&file, main_key).expect("entries"); + assert_eq!(password_of(&entries, title), Some("p@ssw0rd!")); + } + + #[test] + fn a_protected_value_of_an_unknown_inner_stream_is_never_taken_as_the_password() { + let main_key = b"unknown-stream-key"; + let title = "IntelliJ Platform DB \u{2014} unknown"; + let options = KdbxOptions { + inner_stream: InnerStream::Unknown, + ..KdbxOptions::default() + }; + let file = test_kdbx::make_kdbx(main_key, &[TestEntry::new(title, "dbuser", "p@ssw0rd!")], options); + let entries = read(&file, main_key).expect("entries"); + assert_eq!(password_of(&entries, title), Some("")); + } + + #[test] + fn a_gzip_compressed_payload_is_read() { + let main_key = b"compressed-main-key"; + let title = "IntelliJ Platform DB \u{2014} gz"; + let options = KdbxOptions { + compressed: true, + ..KdbxOptions::default() + }; + let file = test_kdbx::make_kdbx(main_key, &[TestEntry::new(title, "dbuser", "p@ssw0rd!")], options); + let entries = read(&file, main_key).expect("entries"); + assert_eq!(password_of(&entries, title), Some("p@ssw0rd!")); + } + + #[test] + fn a_salsa20_inner_stream_decrypts_every_protected_value_in_document_order() { + let main_key = b"salsa-main-key"; + let options = KdbxOptions { + inner_stream: InnerStream::Salsa20, + ..KdbxOptions::default() + }; + let file = test_kdbx::make_kdbx( + main_key, + &[ + TestEntry::new("first", "u", "one"), + TestEntry::new("second", "u", "two"), + ], + options, + ); + let entries = read(&file, main_key).expect("entries"); + assert_eq!(password_of(&entries, "first"), Some("one")); + assert_eq!(password_of(&entries, "second"), Some("two")); + } + + #[test] + fn the_wrong_main_key_is_rejected_by_the_stream_start_check() { + let file = test_kdbx::make_kdbx(b"correct", &[TestEntry::new("t", "u", "p")], KdbxOptions::default()); + assert!(read(&file, b"incorrect").is_err()); + } + + #[test] + fn a_malformed_signature_is_an_error() { + assert_eq!( + read(&[0x00, 0x01, 0x02, 0x03], &[]).err(), + Some(KdbxError::MalformedHeader) + ); + } + + #[test] + fn a_key_derivation_past_the_round_limit_is_refused_before_any_work() { + let header = Header { + transform_rounds: MAX_TRANSFORM_ROUNDS + 1, + ..Header::default() + }; + assert_eq!( + derive_final_key(&header, b"key").err(), + Some(KdbxError::MalformedHeader) + ); + } + + #[test] + fn a_kdbx_4_file_is_unsupported() { + let mut file = test_kdbx::make_kdbx(b"key", &[TestEntry::new("t", "u", "p")], KdbxOptions::default()); + file[8..12].copy_from_slice(&0x0004_0000_u32.to_le_bytes()); + assert_eq!(read(&file, b"key").err(), Some(KdbxError::UnsupportedVersion)); + } + + #[test] + fn a_numeric_header_field_shorter_than_its_type_is_malformed() { + let mut file = Vec::new(); + file.extend_from_slice(&SIGNATURE_1.to_le_bytes()); + file.extend_from_slice(&SIGNATURE_2.to_le_bytes()); + file.extend_from_slice(&0x0003_0001_u32.to_le_bytes()); + file.extend_from_slice(&[6, 2, 0, 0xff, 0xff]); + file.extend_from_slice(&[0, 0, 0]); + assert_eq!(read(&file, b"key").err(), Some(KdbxError::MalformedHeader)); + } + + #[test] + fn a_tampered_block_fails_its_hash() { + let block = b"payload".to_vec(); + let mut framed = Vec::new(); + framed.extend_from_slice(&0_u32.to_le_bytes()); + framed.extend_from_slice(&Sha256::digest(b"other")); + framed.extend_from_slice(&u32::try_from(block.len()).expect("a length").to_le_bytes()); + framed.extend_from_slice(&block); + assert_eq!(dehash_blocks(&framed), None); + } + + #[test] + fn history_entries_are_read_after_their_entry() { + let xml = concat!( + "", + "Titlet", + "Passwordcurrent", + "", + "Titlet", + "Passwordold", + "" + ); + let entries = parse_entries(xml.as_bytes(), None); + let passwords: Vec<&str> = entries.iter().map(|entry| entry.password.expose_secret()).collect(); + assert_eq!(passwords, ["current", "old"]); + } +} diff --git a/core/crates/foreign-import/src/apps/jetbrains/mod.rs b/core/crates/foreign-import/src/apps/jetbrains/mod.rs new file mode 100644 index 0000000000..98a9835887 --- /dev/null +++ b/core/crates/foreign-import/src/apps/jetbrains/mod.rs @@ -0,0 +1,13 @@ +// What every JetBrains IDE shares: its credential store, the KeePass file it can keep secrets in, the +// path macros in its XML, and how that XML is read. + +mod credential_store; +mod inner_stream; +mod kdbx; +mod path_macros; +#[cfg(test)] +pub(crate) mod test_kdbx; +pub(crate) mod xml; + +pub(crate) use credential_store::{CredentialStore, Lookup}; +pub(crate) use path_macros::expand; diff --git a/core/crates/foreign-import/src/apps/jetbrains/path_macros.rs b/core/crates/foreign-import/src/apps/jetbrains/path_macros.rs new file mode 100644 index 0000000000..e232012b51 --- /dev/null +++ b/core/crates/foreign-import/src/apps/jetbrains/path_macros.rs @@ -0,0 +1,19 @@ +use std::path::Path; + +// The IDE writes `$USER_HOME$` into SSH key paths, certificate paths and recent projects. +pub(crate) fn expand(path: &str, home: &Path) -> String { + path.replace("$USER_HOME$", &home.to_string_lossy()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_user_home_macro_becomes_the_home_folder_wherever_it_appears() { + let home = Path::new("/home/me"); + assert_eq!(expand("$USER_HOME$/.ssh/id_ed25519", home), "/home/me/.ssh/id_ed25519"); + assert_eq!(expand("/keys/$USER_HOME$", home), "/keys//home/me"); + assert_eq!(expand("$APPLICATION_CONFIG_DIR$/x", home), "$APPLICATION_CONFIG_DIR$/x"); + } +} diff --git a/core/crates/foreign-import/src/apps/jetbrains/test_kdbx.rs b/core/crates/foreign-import/src/apps/jetbrains/test_kdbx.rs new file mode 100644 index 0000000000..5160bfdbb2 --- /dev/null +++ b/core/crates/foreign-import/src/apps/jetbrains/test_kdbx.rs @@ -0,0 +1,170 @@ +// Writes KDBX 3.1 files and main key files laid out as the IDE writes them, for tests. + +use std::io::Write; + +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockEncryptMut, KeyIvInit}; +use aes::{Aes128, Aes256}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use flate2::Compression; +use flate2::write::GzEncoder; +use sha2::{Digest, Sha256}; + +use super::inner_stream::InnerStreamCipher; +use super::kdbx::aes_kdf; + +pub(crate) const BUILT_IN_KEY: &[u8; 16] = b"Proxy Config Sec"; + +pub(crate) struct TestEntry<'a> { + title: &'a str, + user_name: &'a str, + password: &'a str, +} + +impl<'a> TestEntry<'a> { + pub(crate) fn new(title: &'a str, user_name: &'a str, password: &'a str) -> Self { + Self { + title, + user_name, + password, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum InnerStream { + Salsa20, + ChaCha20, + // An id no reader knows, so values are written as they are, base64 only. + Unknown, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct KdbxOptions { + pub rounds: u64, + pub compressed: bool, + pub inner_stream: InnerStream, +} + +impl Default for KdbxOptions { + fn default() -> Self { + Self { + rounds: 60, + compressed: false, + inner_stream: InnerStream::ChaCha20, + } + } +} + +// Fixed bytes per label, so a written file is the same on every run. +pub(crate) fn bytes(label: &str, count: usize) -> Vec { + let mut output = Vec::with_capacity(count); + let mut counter = 0_u32; + while output.len() < count { + let mut hasher = Sha256::new(); + hasher.update(label.as_bytes()); + hasher.update(counter.to_le_bytes()); + output.extend_from_slice(&hasher.finalize()); + counter += 1; + } + output.truncate(count); + output +} + +pub(crate) fn make_kdbx(main_key: &[u8], entries: &[TestEntry<'_>], options: KdbxOptions) -> Vec { + let main_seed = bytes("main seed", 32); + let transform_seed = bytes("transform seed", 32); + let encryption_iv = bytes("encryption iv", 16); + let protected_stream_key = bytes("protected stream key", 32); + let stream_start_bytes = bytes("stream start", 32); + let stream_id = match options.inner_stream { + InnerStream::Salsa20 => 2_u32, + InnerStream::ChaCha20 => 3, + InnerStream::Unknown => 9, + }; + + let mut cipher = InnerStreamCipher::for_stream(stream_id, &protected_stream_key); + let mut xml = String::from( + "\nIntelliJ Platform\n", + ); + for entry in entries { + let protected = STANDARD.encode(match &mut cipher { + Some(cipher) => cipher.process(entry.password.as_bytes()).expect("ciphertext"), + None => entry.password.as_bytes().to_vec(), + }); + xml.push_str(&format!( + "\nTitle{}\nUserName{}\nPassword{protected}\n\n", + entry.title, entry.user_name + )); + } + xml.push_str("\n"); + let payload = if options.compressed { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(xml.as_bytes()).expect("gzip"); + encoder.finish().expect("gzip") + } else { + xml.into_bytes() + }; + + let mut plaintext = stream_start_bytes.clone(); + plaintext.extend_from_slice(&0_u32.to_le_bytes()); + plaintext.extend_from_slice(&Sha256::digest(&payload)); + plaintext.extend_from_slice(&u32::try_from(payload.len()).expect("a length").to_le_bytes()); + plaintext.extend_from_slice(&payload); + plaintext.extend_from_slice(&1_u32.to_le_bytes()); + plaintext.extend_from_slice(&[0; 32]); + plaintext.extend_from_slice(&0_u32.to_le_bytes()); + + let composite = Sha256::digest(Sha256::digest(main_key)); + let transformed = aes_kdf(&composite, &transform_seed, options.rounds).expect("a key"); + let mut hasher = Sha256::new(); + hasher.update(&main_seed); + hasher.update(Sha256::digest(transformed)); + let final_key = hasher.finalize(); + let ciphertext = cbc::Encryptor::::new_from_slices(&final_key, &encryption_iv) + .expect("a cipher") + .encrypt_padded_vec_mut::(&plaintext); + + let mut file = Vec::new(); + file.extend_from_slice(&0x9AA2_D903_u32.to_le_bytes()); + file.extend_from_slice(&0xB54B_FB67_u32.to_le_bytes()); + file.extend_from_slice(&0x0003_0001_u32.to_le_bytes()); + let compression = u32::from(options.compressed); + for (field, data) in [ + (3_u8, compression.to_le_bytes().to_vec()), + (4, main_seed), + (5, transform_seed), + (6, options.rounds.to_le_bytes().to_vec()), + (7, encryption_iv), + (8, protected_stream_key), + (9, stream_start_bytes), + (10, stream_id.to_le_bytes().to_vec()), + (0, vec![0x0d, 0x0a, 0x0d, 0x0a]), + ] { + file.push(field); + file.extend_from_slice(&u16::try_from(data.len()).expect("a length").to_le_bytes()); + file.extend_from_slice(&data); + } + file.extend_from_slice(&ciphertext); + file +} + +// The main key under BUILT_IN: a big-endian IV length, the IV, then AES-128-CBC. +pub(crate) fn built_in_blob(main_key: &[u8]) -> Vec { + let iv = bytes("main key iv", 16); + let ciphertext = cbc::Encryptor::::new_from_slices(BUILT_IN_KEY, &iv) + .expect("a cipher") + .encrypt_padded_vec_mut::(main_key); + let mut blob = 16_u32.to_be_bytes().to_vec(); + blob.extend_from_slice(&iv); + blob.extend_from_slice(&ciphertext); + blob +} + +pub(crate) fn main_key_file(main_key: &[u8]) -> String { + format!( + "encryption: BUILT_IN\nisAutoGenerated: true\nvalue: !!binary {}\n", + STANDARD.encode(built_in_blob(main_key)) + ) +} diff --git a/core/crates/foreign-import/src/apps/jetbrains/xml.rs b/core/crates/foreign-import/src/apps/jetbrains/xml.rs new file mode 100644 index 0000000000..abcf068159 --- /dev/null +++ b/core/crates/foreign-import/src/apps/jetbrains/xml.rs @@ -0,0 +1,53 @@ +use roxmltree::{Document, Node}; + +// The IDE writes its XML as UTF-8. roxmltree refuses a DTD, so no entity is ever loaded. +pub(crate) fn document(data: &[u8]) -> Option> { + let text = std::str::from_utf8(data).ok()?; + Document::parse(text.strip_prefix('\u{FEFF}').unwrap_or(text)).ok() +} + +pub(crate) fn elements<'a, 'input>( + document: &'a Document<'input>, + name: &'a str, +) -> impl Iterator> + 'a { + document + .descendants() + .filter(move |node| node.is_element() && node.has_tag_name(name)) +} + +pub(crate) fn child<'a, 'input>(node: Node<'a, 'input>, name: &str) -> Option> { + node.children() + .find(|child| child.is_element() && child.has_tag_name(name)) +} + +// All the text under the node, as Foundation's `stringValue` gives it. +pub(crate) fn text(node: Node<'_, '_>) -> String { + node.descendants() + .filter(Node::is_text) + .filter_map(|text| text.text()) + .collect() +} + +pub(crate) fn child_text(node: Node<'_, '_>, name: &str) -> Option { + child(node, name).map(|child| text(child).trim().to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_joins_every_text_under_an_element_and_child_text_is_trimmed() { + let data = "\u{FEFF} one two\n".as_bytes(); + let document = document(data).expect("a document"); + let root = document.root_element(); + assert_eq!(child_text(root, "b").as_deref(), Some("one two three")); + assert_eq!(child_text(root, "missing"), None); + } + + #[test] + fn a_document_with_a_dtd_or_bad_bytes_is_not_read() { + assert!(document(b"]>&e;").is_none()); + assert!(document(&[0xff, 0xfe, b'<']).is_none()); + } +} diff --git a/core/crates/foreign-import/src/apps/mod.rs b/core/crates/foreign-import/src/apps/mod.rs new file mode 100644 index 0000000000..be5e0395b8 --- /dev/null +++ b/core/crates/foreign-import/src/apps/mod.rs @@ -0,0 +1,42 @@ +pub(crate) mod beekeeper; +pub(crate) mod datagrip; +pub(crate) mod dbeaver; +pub(crate) mod jdbc; +pub(crate) mod jetbrains; +pub(crate) mod navicat; +pub(crate) mod sequel_ace; +pub(crate) mod tableplus; + +// A key path the app saved as a bare file name lives in `~/.ssh`, as ssh reads it. +pub(crate) fn resolve_key_path(path: &str) -> String { + if path.is_empty() { + return String::new(); + } + if path.starts_with('/') || path.starts_with("~/") || is_windows_absolute(path) { + return path.to_owned(); + } + format!("~/.ssh/{path}") +} + +fn is_windows_absolute(path: &str) -> bool { + let bytes = path.as_bytes(); + path.starts_with("\\\\") + || (bytes.len() > 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'\\' || bytes[2] == b'/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_key_name_is_read_from_the_ssh_folder() { + assert_eq!(resolve_key_path(""), ""); + assert_eq!(resolve_key_path("id_ed25519"), "~/.ssh/id_ed25519"); + assert_eq!(resolve_key_path("/keys/id"), "/keys/id"); + assert_eq!(resolve_key_path("~/keys/id"), "~/keys/id"); + assert_eq!(resolve_key_path("C:\\Users\\me\\.ssh\\id"), "C:\\Users\\me\\.ssh\\id"); + } +} diff --git a/core/crates/foreign-import/src/apps/navicat/cipher.rs b/core/crates/foreign-import/src/apps/navicat/cipher.rs new file mode 100644 index 0000000000..afb22a887d --- /dev/null +++ b/core/crates/foreign-import/src/apps/navicat/cipher.rs @@ -0,0 +1,161 @@ +// The fixed-key ciphers of an .ncx export: github.com/HyperSine/how-does-navicat-encrypt-password + +use aes::Aes128; +use blowfish::Blowfish; +use blowfish::cipher::generic_array::GenericArray; +use blowfish::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; +use cbc::cipher::block_padding::NoPadding; +use cbc::cipher::{BlockDecryptMut, KeyIvInit}; +use sha1::{Digest, Sha1}; + +const AES_KEY: &[u8; 16] = b"libcckeylibcckey"; +const AES_IV: &[u8; 16] = b"libcciv libcciv "; +const AES_BLOCK: usize = 16; +const BLOWFISH_KEY_SEED: &[u8] = b"3DC5CA39"; +const BLOWFISH_BLOCK: usize = 8; + +pub(super) fn decrypt(hex: &str) -> Option { + if hex.is_empty() { + return None; + } + let ciphertext = hex::decode(hex).ok()?; + decrypt_v2(&ciphertext) + .filter(|value| is_plausible_text(value)) + .or_else(|| decrypt_v1(&ciphertext).filter(|value| is_plausible_text(value))) +} + +fn is_plausible_text(value: &str) -> bool { + !value.is_empty() && !value.chars().any(char::is_control) +} + +// Navicat 12 and later: AES-128-CBC. +fn decrypt_v2(ciphertext: &[u8]) -> Option { + if ciphertext.is_empty() || !ciphertext.len().is_multiple_of(AES_BLOCK) { + return None; + } + let mut buffer = ciphertext.to_vec(); + let length = cbc::Decryptor::::new(AES_KEY.into(), AES_IV.into()) + .decrypt_padded_mut::(&mut buffer) + .ok()? + .len(); + buffer.truncate(length); + // As CommonCrypto unpads for the macOS app: the last byte alone decides, and 0 or past a block strips nothing. + if let Some(&pad) = buffer.last() { + let pad = usize::from(pad); + if (1..=AES_BLOCK).contains(&pad) { + buffer.truncate(buffer.len().saturating_sub(pad)); + } + } + utf8_text(buffer) +} + +// Navicat 11: Blowfish-ECB in a CBC-like XOR chain, the tail XORed with a keystream block. +fn decrypt_v1(ciphertext: &[u8]) -> Option { + if ciphertext.is_empty() { + return None; + } + let cipher: Blowfish = Blowfish::new_from_slice(&Sha1::digest(BLOWFISH_KEY_SEED)).ok()?; + let mut vector = [0xFF_u8; BLOWFISH_BLOCK]; + cipher.encrypt_block(GenericArray::from_mut_slice(&mut vector)); + + let mut output = Vec::with_capacity(ciphertext.len()); + let (blocks, tail) = ciphertext.as_chunks::(); + for block in blocks { + let mut decrypted = *block; + cipher.decrypt_block(GenericArray::from_mut_slice(&mut decrypted)); + output.extend(decrypted.iter().zip(&vector).map(|(byte, mask)| byte ^ mask)); + vector.iter_mut().zip(block).for_each(|(mask, byte)| *mask ^= byte); + } + + if !tail.is_empty() { + let mut keystream = vector; + cipher.encrypt_block(GenericArray::from_mut_slice(&mut keystream)); + output.extend(tail.iter().zip(&keystream).map(|(byte, mask)| byte ^ mask)); + } + utf8_text(output) +} + +// Foundation drops one leading byte order mark when it decodes UTF-8. +fn utf8_text(bytes: Vec) -> Option { + let text = String::from_utf8(bytes).ok()?; + Some(match text.strip_prefix('\u{FEFF}') { + Some(rest) => rest.to_owned(), + None => text, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decrypts_a_navicat_12_aes_password() { + assert_eq!( + decrypt("B75D320B6211468D63EB3B67C9E85933").as_deref(), + Some("This is a test") + ); + assert_eq!( + decrypt("b75d320b6211468d63eb3b67c9e85933").as_deref(), + Some("This is a test") + ); + } + + #[test] + fn decrypts_a_navicat_11_blowfish_password() { + assert_eq!( + decrypt("0EA71F51DD37BFB60CCBA219BE3A").as_deref(), + Some("This is a test") + ); + } + + #[test] + fn decrypts_a_navicat_11_password_whose_length_is_a_multiple_of_the_aes_block() { + assert_eq!( + decrypt("2E6C8CF471EB0268D3239A0AD531F1B1").as_deref(), + Some("Sup3rSecret!Pass") + ); + } + + #[test] + fn decrypts_multibyte_text() { + assert_eq!( + decrypt("539E5461C296451492DD78894233BE0DB9292731D8497E89821698B21B2F7CCF").as_deref(), + Some("mật khẩu🔑") + ); + } + + #[test] + fn padding_is_read_as_common_crypto_reads_it() { + // "ABCDEFGHIJKLMN" + 0x41 0x02: only the last byte is a valid pad. + assert_eq!( + decrypt("16BF1E24F06B2B6CF645505368F03156").as_deref(), + Some("ABCDEFGHIJKLMN") + ); + } + + #[test] + fn a_leading_byte_order_mark_is_dropped() { + assert_eq!(decrypt("0284E3E4F7E77AD6A46C6D4242B0D660").as_deref(), Some("secret")); + } + + #[test] + fn a_password_with_a_control_character_is_not_plausible() { + // "line\nbreak" under AES; Blowfish of the same bytes is not text either. + assert_eq!(decrypt("D118EB44C0B5AC5E840CA6293D42E042"), None); + } + + #[test] + fn returns_nothing_for_an_empty_string() { + assert_eq!(decrypt(""), None); + } + + #[test] + fn returns_nothing_for_odd_length_hex() { + assert_eq!(decrypt("ABC"), None); + } + + #[test] + fn returns_nothing_for_non_hex_input() { + assert_eq!(decrypt("ZZZZ"), None); + } +} diff --git a/core/crates/foreign-import/src/apps/navicat/mod.rs b/core/crates/foreign-import/src/apps/navicat/mod.rs new file mode 100644 index 0000000000..c35c787c7e --- /dev/null +++ b/core/crates/foreign-import/src/apps/navicat/mod.rs @@ -0,0 +1,237 @@ +mod cipher; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use roxmltree::{Document, Node}; +use secrecy::SecretString; +use tokio_util::sync::CancellationToken; + +use crate::app::{AppReader, check_cancel}; +use crate::apps::resolve_key_path; +use crate::collect::assemble; +use crate::{ + CollectedImport, ForeignApp, ForeignConnection, ForeignConnectionRecord, ForeignCredentials, ForeignImportError, + ForeignSsh, ForeignSshAuth, ForeignSsl, ForeignSslMode, ImportRequest, Inventory, PasswordOutcome, engine, +}; + +pub(crate) struct Navicat { + file: Option, +} + +impl Navicat { + pub(crate) fn new(file: Option) -> Self { + Self { file } + } + + fn read(&self) -> Result { + let not_found = || ForeignImportError::FileNotFound { + app: ForeignApp::Navicat, + }; + let path = self.file.as_ref().ok_or_else(not_found)?; + let data = crate::files::read_capped(path).map_err(|_| not_found())?; + String::from_utf8(data).map_err(|_| ForeignImportError::parse("the export is not UTF-8 text")) + } +} + +impl AppReader for Navicat { + fn has_data(&self) -> bool { + self.file.is_some() + } + + fn inventory(&self) -> Inventory { + let connections = self + .read() + .ok() + .and_then(|text| parse(&text).ok().map(|document| connection_elements(&document).count())) + .unwrap_or(0); + Inventory { + connections, + saved_queries: 0, + } + } + + fn collect( + &self, + request: &ImportRequest, + cancel: &CancellationToken, + ) -> Result { + let text = self.read()?; + let document = parse(&text)?; + let mut records = Vec::new(); + for element in connection_elements(&document) { + check_cancel(cancel)?; + records.push(ForeignConnectionRecord { + source_id: None, + settings: connection(element), + group_path: Vec::new(), + credentials: if request.include_passwords { + credentials(element) + } else { + None + }, + }); + } + let passwords = if request.include_passwords { + PasswordOutcome::Read + } else { + PasswordOutcome::NotRequested + }; + assemble(ForeignApp::Navicat, records, Vec::new(), passwords) + } +} + +// The default options refuse a document type, so no entity of any kind is expanded. +fn parse(text: &str) -> Result, ForeignImportError> { + Document::parse(text).map_err(|error| ForeignImportError::parse(error.to_string())) +} + +// Every `Connection` element at any depth, as the XPath `//Connection` selects. +fn connection_elements<'a, 'input>(document: &'a Document<'input>) -> impl Iterator> { + document.descendants().filter(|node| { + node.is_element() && node.tag_name().name() == "Connection" && node.tag_name().namespace().is_none() + }) +} + +fn connection(element: Node<'_, '_>) -> ForeignConnection { + let engine = engine_name(attribute(element, "ConnType")); + let is_file_based = engine == "SQLite"; + let port = if is_file_based { + 0 + } else { + attribute(element, "Port") + .parse::() + .ok() + .and_then(|port| u16::try_from(port).ok()) + .unwrap_or_else(|| engine::default_port(&engine)) + }; + ForeignConnection { + name: non_empty(attribute(element, "ConnectionName")) + .unwrap_or("Navicat") + .to_owned(), + host: if is_file_based { + String::new() + } else { + non_empty(attribute(element, "Host")).unwrap_or("localhost").to_owned() + }, + port, + database: attribute(element, if is_file_based { "DatabaseFileName" } else { "Database" }).to_owned(), + username: if is_file_based { + String::new() + } else { + attribute(element, "UserName").to_owned() + }, + ssh: ssh(element), + ssl: ssl(element), + color: None, + additional_fields: BTreeMap::new(), + engine, + } +} + +fn ssh(element: Node<'_, '_>) -> Option { + if !is_true(attribute(element, "SSH")) { + return None; + } + let uses_key = attribute(element, "SSH_AuthenMethod").to_uppercase() != "PASSWORD"; + Some(ForeignSsh { + host: attribute(element, "SSH_Host").to_owned(), + port: attribute(element, "SSH_Port") + .parse::() + .ok() + .and_then(|port| u16::try_from(port).ok()), + username: attribute(element, "SSH_UserName").to_owned(), + auth: if uses_key { + ForeignSshAuth::PrivateKey + } else { + ForeignSshAuth::Password + }, + private_key_path: if uses_key { + resolve_key_path(attribute(element, "SSH_PrivateKey")) + } else { + String::new() + }, + jump_hosts: Vec::new(), + }) +} + +fn ssl(element: Node<'_, '_>) -> Option { + if !is_true(attribute(element, "SSL")) { + return None; + } + let path = |name: &str| non_empty(attribute(element, name)).map(str::to_owned); + Some(ForeignSsl { + mode: ssl_mode(attribute(element, "SSL_PGSSLMode")), + ca_certificate_path: path("SSL_CACert"), + client_certificate_path: path("SSL_ClientCert"), + client_key_path: path("SSL_ClientKey"), + }) +} + +fn credentials(element: Node<'_, '_>) -> Option { + let saved = |flag: &str, field: &str| { + if is_true(attribute(element, flag)) { + decrypt_field(attribute(element, field)) + } else { + None + } + }; + let password = saved("SavePassword", "Password"); + let ssh_password = saved("SSH_SavePassword", "SSH_Password"); + if password.is_none() && ssh_password.is_none() { + return None; + } + Some(ForeignCredentials { + password, + ssh_password, + key_passphrase: None, + }) +} + +fn decrypt_field(hex: &str) -> Option { + let trimmed = hex.trim(); + if trimmed.is_empty() { + return None; + } + match cipher::decrypt(trimmed) { + Some(value) => Some(SecretString::from(value)), + None => { + tracing::warn!("could not decrypt a Navicat password field"); + None + } + } +} + +fn engine_name(raw: &str) -> String { + match raw.to_uppercase().as_str() { + "MYSQL" => "MySQL".to_owned(), + "MARIADB" => "MariaDB".to_owned(), + "POSTGRESQL" => "PostgreSQL".to_owned(), + "ORACLE" => "Oracle".to_owned(), + "SQLITE" => "SQLite".to_owned(), + "SQLSERVER" => "SQL Server".to_owned(), + "MONGODB" => "MongoDB".to_owned(), + _ => engine::resolve(raw), + } +} + +fn ssl_mode(raw: &str) -> ForeignSslMode { + match raw.to_uppercase().as_str() { + "VERIFY-CA" => ForeignSslMode::VerifyCa, + "VERIFY-FULL" => ForeignSslMode::VerifyIdentity, + "PREFER" | "ALLOW" => ForeignSslMode::Preferred, + _ => ForeignSslMode::Required, + } +} + +fn attribute<'a>(element: Node<'a, '_>, name: &str) -> &'a str { + element.attribute(name).unwrap_or_default() +} + +fn is_true(value: &str) -> bool { + value.to_lowercase() == "true" +} + +fn non_empty(value: &str) -> Option<&str> { + (!value.is_empty()).then_some(value) +} diff --git a/core/crates/foreign-import/src/apps/sequel_ace/mod.rs b/core/crates/foreign-import/src/apps/sequel_ace/mod.rs new file mode 100644 index 0000000000..5e06ab59a2 --- /dev/null +++ b/core/crates/foreign-import/src/apps/sequel_ace/mod.rs @@ -0,0 +1,432 @@ +mod query_favorites; + +use std::collections::BTreeMap; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use plist::{Dictionary, Value}; +use secrecy::SecretString; +use tokio_util::sync::CancellationToken; + +use crate::app::{AppReader, check_cancel}; +use crate::apps::resolve_key_path; +use crate::collect::assemble; +use crate::{ + CollectedImport, ForeignApp, ForeignAppDirs, ForeignColor, ForeignConnection, ForeignConnectionRecord, + ForeignCredentials, ForeignImportError, ForeignKeychain, ForeignSsh, ForeignSshAuth, ForeignSsl, ForeignSslMode, + ImportRequest, Inventory, KeychainRead, PasswordOutcome, +}; + +const DEFAULT_PORT: u16 = 3306; +const SSH_TUNNEL_TYPE: i64 = 2; + +pub(crate) struct SequelAce { + favorites: PathBuf, + query_favorites: PathBuf, + keychain: Option>, +} + +impl SequelAce { + pub(crate) fn new( + favorites: PathBuf, + query_favorites: PathBuf, + keychain: Option>, + ) -> Self { + Self { + favorites, + query_favorites, + keychain, + } + } + + pub(crate) fn locate(dirs: &ForeignAppDirs, keychain: Option>) -> Self { + let library = ["Library", "Containers", "com.sequel-ace.sequel-ace", "Data", "Library"] + .iter() + .fold(dirs.home.clone(), |path, part| path.join(part)); + Self::new( + library + .join("Application Support") + .join("Sequel Ace") + .join("Data") + .join("Favorites.plist"), + // Query favorites live in the app's sandboxed preferences, not beside the connections. + library.join("Preferences").join("com.sequel-ace.sequel-ace.plist"), + keychain, + ) + } +} + +impl AppReader for SequelAce { + fn has_data(&self) -> bool { + self.favorites.exists() + } + + fn inventory(&self) -> Inventory { + let connections = read_dictionary(&self.favorites) + .as_ref() + .and_then(favorites_children) + .map_or(0, |children| count_connections(&children)); + Inventory { + connections, + saved_queries: query_favorites::count(&self.query_favorites), + } + } + + fn collect( + &self, + request: &ImportRequest, + cancel: &CancellationToken, + ) -> Result { + if !self.favorites.exists() { + return Err(ForeignImportError::FileNotFound { + app: ForeignApp::SequelAce, + }); + } + let root = read_dictionary(&self.favorites) + .ok_or_else(|| ForeignImportError::parse("Could not read Favorites.plist"))?; + let children = favorites_children(&root) + .ok_or_else(|| ForeignImportError::unsupported("Missing Favorites Root or Children key"))?; + + let mut passwords = match (request.include_passwords, self.keychain.as_deref()) { + (true, Some(keychain)) => Some(KeychainPasswords { + keychain, + stopped: false, + }), + _ => None, + }; + let mut records = Vec::new(); + parse_children(&children, &[], &mut passwords, &mut records, cancel)?; + + let saved_queries = if request.include_saved_queries { + query_favorites::saved_queries(&self.query_favorites, request.saved_query_byte_limit, cancel)? + } else { + Vec::new() + }; + + let outcome = match passwords { + _ if !request.include_passwords => PasswordOutcome::NotRequested, + None => PasswordOutcome::LeftInKeychain, + Some(passwords) if passwords.stopped => PasswordOutcome::Stopped, + Some(_) => PasswordOutcome::Read, + }; + assemble(ForeignApp::SequelAce, records, saved_queries, outcome) + } +} + +fn read_dictionary(path: &Path) -> Option { + let data = crate::files::read_capped(path).ok()?; + Value::from_reader(Cursor::new(data)).ok()?.into_dictionary() +} + +fn favorites_children(root: &Dictionary) -> Option> { + let favorites_root = root.get("Favorites Root")?.as_dictionary()?; + dictionaries(favorites_root.get("Children")) +} + +// A list counts only when every item is a dictionary, as Swift's cast to `[[String: Any]]` does. +fn dictionaries(value: Option<&Value>) -> Option> { + value?.as_array()?.iter().map(Value::as_dictionary).collect() +} + +// The rule `parse_children` reads by, so the count says what an import brings. The macOS app +// counts by `host` and a numeric `id` instead, which can disagree with what it reads. +fn count_connections(children: &[&Dictionary]) -> usize { + children + .iter() + .map(|child| match dictionaries(child.get("Children")) { + Some(children) => count_connections(&children), + None => 1, + }) + .sum() +} + +fn parse_children( + children: &[&Dictionary], + group_path: &[String], + passwords: &mut Option>, + records: &mut Vec, + cancel: &CancellationToken, +) -> Result<(), ForeignImportError> { + for child in children { + check_cancel(cancel)?; + if let Some(sub_children) = dictionaries(child.get("Children")) { + let mut path = group_path.to_vec(); + path.push(text(child, "Name").unwrap_or("Untitled Group").to_owned()); + parse_children(&sub_children, &path, passwords, records, cancel)?; + continue; + } + let credentials = passwords.as_mut().and_then(|passwords| passwords.credentials(child)); + records.push(ForeignConnectionRecord { + source_id: number_or_text(child.get("id")), + settings: connection(child), + group_path: group_path.to_vec(), + credentials, + }); + } + Ok(()) +} + +fn connection(entry: &Dictionary) -> ForeignConnection { + let connection_type = integer(entry.get("type")).unwrap_or(0); + ForeignConnection { + name: text(entry, "name").unwrap_or("Untitled").to_owned(), + host: text(entry, "host").unwrap_or("localhost").to_owned(), + port: port(entry.get("port")).unwrap_or(DEFAULT_PORT), + database: text(entry, "database").unwrap_or_default().to_owned(), + username: text(entry, "user").unwrap_or_default().to_owned(), + engine: "MySQL".to_owned(), + ssh: ssh(entry, connection_type), + ssl: ssl(entry), + color: color(integer(entry.get("colorIndex")).unwrap_or(-1)), + additional_fields: BTreeMap::new(), + } +} + +fn ssh(entry: &Dictionary, connection_type: i64) -> Option { + if connection_type != SSH_TUNNEL_TYPE { + return None; + } + let key_enabled = integer(entry.get("sshKeyLocationEnabled")).unwrap_or(0) != 0; + Some(ForeignSsh { + host: text(entry, "sshHost").unwrap_or_default().to_owned(), + port: port(entry.get("sshPort")), + username: text(entry, "sshUser").unwrap_or_default().to_owned(), + auth: if key_enabled { + ForeignSshAuth::PrivateKey + } else { + ForeignSshAuth::Password + }, + private_key_path: if key_enabled { + resolve_key_path(text(entry, "sshKeyLocation").unwrap_or_default()) + } else { + String::new() + }, + jump_hosts: Vec::new(), + }) +} + +fn ssl(entry: &Dictionary) -> Option { + if integer(entry.get("useSSL")).unwrap_or(0) == 0 { + return None; + } + let path = |key: &str| text(entry, key).map(str::to_owned); + Some(ForeignSsl { + mode: ForeignSslMode::Required, + ca_certificate_path: path("sslCACertFileLocation"), + client_certificate_path: path("sslCertificateFileLocation"), + client_key_path: path("sslKeyFileLocation"), + }) +} + +fn color(index: i64) -> Option { + match index { + 0 => Some(ForeignColor::Red), + 1 => Some(ForeignColor::Orange), + 2 => Some(ForeignColor::Yellow), + 3 => Some(ForeignColor::Green), + 4 => Some(ForeignColor::Blue), + 5 => Some(ForeignColor::Purple), + 6 => Some(ForeignColor::Pink), + 7 => Some(ForeignColor::Gray), + _ => None, + } +} + +struct KeychainPasswords<'a> { + keychain: &'a dyn ForeignKeychain, + stopped: bool, +} + +impl KeychainPasswords<'_> { + fn credentials(&mut self, entry: &Dictionary) -> Option { + if self.stopped { + return None; + } + let name = text(entry, "name").unwrap_or_default(); + let id = number_or_text(entry.get("id")).unwrap_or_else(|| "0".to_owned()); + let user = text(entry, "user").unwrap_or_default(); + let host = text(entry, "host").unwrap_or_default(); + let database = text(entry, "database").unwrap_or_default(); + + let password = self.read( + &format!("Sequel Ace : {name} ({id})"), + &format!("{user}@{host}/{database}"), + ); + let ssh_password = if integer(entry.get("type")).unwrap_or(0) == SSH_TUNNEL_TYPE { + let ssh_user = text(entry, "sshUser").unwrap_or_default(); + let ssh_host = text(entry, "sshHost").unwrap_or_default(); + self.read( + &format!("Sequel Ace SSHTunnel : {name} ({id})"), + &format!("{ssh_user}@{ssh_host}"), + ) + } else { + None + }; + + if password.is_none() && ssh_password.is_none() { + return None; + } + Some(ForeignCredentials { + password, + ssh_password, + key_passphrase: None, + }) + } + + fn read(&mut self, service: &str, account: &str) -> Option { + if self.stopped { + return None; + } + match self.keychain.read_password(service, Some(account)) { + KeychainRead::Found(password) => Some(password), + KeychainRead::NotFound => None, + KeychainRead::Denied => { + tracing::info!("the keychain refused a Sequel Ace password, so the rest are not read"); + self.stopped = true; + None + } + } + } +} + +fn text<'a>(entry: &'a Dictionary, key: &str) -> Option<&'a str> { + entry.get(key).and_then(Value::as_string) +} + +// What Swift's `as? Int` gives for a property list value: a boolean is 0 or 1, a real only when whole. +fn integer(value: Option<&Value>) -> Option { + match value? { + Value::Integer(number) => number.as_signed(), + Value::Boolean(flag) => Some(i64::from(*flag)), + Value::Real(real) => whole(*real), + _ => None, + } +} + +fn whole(real: f64) -> Option { + // 2^63: the first whole value past i64::MAX, and the magnitude of i64::MIN. + const LIMIT: f64 = 9_223_372_036_854_775_808.0; + (real.fract() == 0.0 && (-LIMIT..LIMIT).contains(&real)).then_some(real as i64) +} + +// A number, else a decimal string. A port past what a port can hold reads as no port. +fn port(value: Option<&Value>) -> Option { + let number = integer(value).or_else(|| value?.as_string()?.parse::().ok())?; + u16::try_from(number).ok() +} + +// NSNumber's `stringValue`, or the string itself. +fn number_or_text(value: Option<&Value>) -> Option { + match value? { + Value::Integer(number) => Some(number.to_string()), + Value::Boolean(flag) => Some(u8::from(*flag).to_string()), + Value::Real(real) => Some(whole(*real).map_or_else(|| real.to_string(), |number| number.to_string())), + Value::String(text) => Some(text.clone()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dictionary(pairs: &[(&str, Value)]) -> Dictionary { + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), value.clone())) + .collect() + } + + #[test] + fn numbers_read_as_swift_reads_them_as_int() { + assert_eq!(integer(Some(&Value::Integer(2.into()))), Some(2)); + assert_eq!(integer(Some(&Value::Boolean(true))), Some(1)); + assert_eq!(integer(Some(&Value::Boolean(false))), Some(0)); + assert_eq!(integer(Some(&Value::Real(3306.0))), Some(3306)); + assert_eq!(integer(Some(&Value::Real(3306.5))), None); + assert_eq!(integer(Some(&Value::Real(f64::NAN))), None); + assert_eq!(integer(Some(&Value::Integer(u64::MAX.into()))), None); + assert_eq!(integer(Some(&Value::String("1".to_owned()))), None); + assert_eq!(integer(None), None); + } + + #[test] + fn an_id_reads_as_nsnumber_string_value_or_as_text() { + assert_eq!( + number_or_text(Some(&Value::Integer((-42).into()))).as_deref(), + Some("-42") + ); + assert_eq!( + number_or_text(Some(&Value::Integer(u64::MAX.into()))).as_deref(), + Some("18446744073709551615") + ); + assert_eq!(number_or_text(Some(&Value::Boolean(true))).as_deref(), Some("1")); + assert_eq!(number_or_text(Some(&Value::Real(42.0))).as_deref(), Some("42")); + assert_eq!(number_or_text(Some(&Value::Real(1.5))).as_deref(), Some("1.5")); + assert_eq!( + number_or_text(Some(&Value::String("abc".to_owned()))).as_deref(), + Some("abc") + ); + assert_eq!(number_or_text(Some(&Value::Data(vec![1]))), None); + } + + #[test] + fn a_port_is_a_number_or_a_decimal_string_that_fits() { + assert_eq!(port(Some(&Value::Integer(2222.into()))), Some(2222)); + assert_eq!(port(Some(&Value::String("3333".to_owned()))), Some(3333)); + assert_eq!(port(Some(&Value::String(String::new()))), None); + assert_eq!(port(Some(&Value::String(" 22".to_owned()))), None); + assert_eq!(port(Some(&Value::Integer(70_000.into()))), None); + assert_eq!(port(None), None); + } + + #[test] + fn a_children_list_with_a_non_dictionary_item_is_not_a_group() { + let entry = dictionary(&[ + ("name", Value::String("Odd".to_owned())), + ("Children", Value::Array(vec![Value::String("x".to_owned())])), + ]); + assert!(dictionaries(entry.get("Children")).is_none()); + let mut records = Vec::new(); + parse_children(&[&entry], &[], &mut None, &mut records, &CancellationToken::new()).expect("parsed"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].settings.name, "Odd"); + assert_eq!(count_connections(&[&entry]), 1); + } + + #[test] + fn counting_follows_the_same_children_rule_as_reading() { + let group_with_host = dictionary(&[ + ("host", Value::String("db".to_owned())), + ( + "Children", + Value::Array(vec![ + Value::Dictionary(dictionary(&[("host", Value::String("a".to_owned()))])), + Value::Dictionary(dictionary(&[("host", Value::String("b".to_owned()))])), + ]), + ), + ]); + let bare = dictionary(&[("name", Value::String("Bare".to_owned()))]); + let numbered = dictionary(&[("id", Value::Integer(7.into()))]); + let children = [&group_with_host, &bare, &numbered]; + assert_eq!(count_connections(&children), 4); + + let mut records = Vec::new(); + parse_children(&children, &[], &mut None, &mut records, &CancellationToken::new()).expect("parsed"); + let names: Vec<&str> = records.iter().map(|record| record.settings.name.as_str()).collect(); + assert_eq!(names, ["Untitled", "Untitled", "Bare", "Untitled"]); + assert_eq!(records[0].group_path, ["Untitled Group"]); + assert_eq!(records[2].settings.host, "localhost"); + assert_eq!(records[3].source_id.as_deref(), Some("7")); + } + + #[test] + fn reading_stops_when_cancelled() { + let entry = dictionary(&[("name", Value::String("A".to_owned()))]); + let cancel = CancellationToken::new(); + cancel.cancel(); + let outcome = parse_children(&[&entry], &[], &mut None, &mut Vec::new(), &cancel); + assert!(matches!(outcome, Err(ForeignImportError::Cancelled))); + } +} diff --git a/core/crates/foreign-import/src/apps/sequel_ace/query_favorites.rs b/core/crates/foreign-import/src/apps/sequel_ace/query_favorites.rs new file mode 100644 index 0000000000..4e91d2f41c --- /dev/null +++ b/core/crates/foreign-import/src/apps/sequel_ace/query_favorites.rs @@ -0,0 +1,59 @@ +use std::path::Path; + +use plist::{Dictionary, Value}; +use tokio_util::sync::CancellationToken; + +use super::{read_dictionary, text}; +use crate::app::check_cancel; +use crate::{ForeignImportError, ForeignSavedQuery, SavedQueryContent}; + +pub(super) fn saved_queries( + preferences: &Path, + limit: u64, + cancel: &CancellationToken, +) -> Result, ForeignImportError> { + let mut queries = Vec::new(); + for favorite in favorites(preferences) { + check_cancel(cancel)?; + let Some(sql) = query_text(&favorite) else { + continue; + }; + let byte_count = u64::try_from(sql.len()).unwrap_or(u64::MAX); + queries.push(ForeignSavedQuery { + name: text(&favorite, "name").unwrap_or_default().to_owned(), + content: if byte_count > limit { + SavedQueryContent::Oversized { byte_count } + } else { + SavedQueryContent::Text(sql.to_owned()) + }, + keyword: text(&favorite, "tabtrigger").map(str::to_owned), + folder_path: Vec::new(), + source_connection_id: None, + is_auto_named: false, + }); + } + Ok(queries) +} + +pub(super) fn count(preferences: &Path) -> usize { + favorites(preferences) + .iter() + .filter(|favorite| query_text(favorite).is_some()) + .count() +} + +fn query_text(favorite: &Dictionary) -> Option<&str> { + text(favorite, "query").filter(|sql| !sql.trim().is_empty()) +} + +fn favorites(preferences: &Path) -> Vec { + let Some(Value::Array(items)) = read_dictionary(preferences).and_then(|mut root| root.remove("queryFavorites")) + else { + return Vec::new(); + }; + items + .into_iter() + .map(Value::into_dictionary) + .collect::>>() + .unwrap_or_default() +} diff --git a/core/crates/foreign-import/src/apps/tableplus/connection.rs b/core/crates/foreign-import/src/apps/tableplus/connection.rs new file mode 100644 index 0000000000..722679e210 --- /dev/null +++ b/core/crates/foreign-import/src/apps/tableplus/connection.rs @@ -0,0 +1,516 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::Path; + +use plist::{Dictionary, Value}; + +use super::form_values::{PasswordMode, tls_form}; +use crate::apps::resolve_key_path; +use crate::engine::{self, LocalFilePathField}; +use crate::{ForeignAppDirs, ForeignColor, ForeignConnection, ForeignSsh, ForeignSshAuth, ForeignSsl}; + +// The macOS library nests groups three deep; it cuts a deeper path there too. +const MAXIMUM_GROUP_DEPTH: usize = 3; + +// The connection files differ by platform: the Windows app writes the macOS keys with its own +// value types, and the Linux app writes JSON under its own names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Dialect { + MacOs, + Windows, + Linux, +} + +// One saved connection with the macOS key names, whatever file it came from. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct Entry { + pub id: Option, + pub name: Option, + pub driver: Option, + pub host: Option, + pub port: Option, + pub user: Option, + pub database_name: Option, + pub database_path: Option, + pub group_id: Option, + pub over_ssh: bool, + pub ssh_host: Option, + pub ssh_port: Option, + pub ssh_user: Option, + pub use_private_key: bool, + pub private_key_name: Option, + // Present when the connection has the `tLSMode` key at all. + pub tls_mode: Option, + pub tls_key_paths: Vec, + // TLS settings whose meaning is not known, kept only to say they were not read. + pub tls_unread: bool, + pub environment: Option, + pub database_password_mode: Option, + pub server_password_mode: Option, +} + +impl Entry { + pub(super) fn from_plist(entry: &Dictionary) -> Self { + let text = |key: &str| entry.get(key).and_then(Value::as_string).map(str::to_owned); + Self { + id: text("ID"), + name: text("ConnectionName"), + driver: text("Driver"), + host: text("DatabaseHost"), + port: entry.get("DatabasePort").and_then(port), + user: text("DatabaseUser"), + database_name: text("DatabaseName"), + database_path: text("DatabasePath"), + group_id: text("GroupID"), + over_ssh: entry.get("isOverSSH").is_some_and(flag), + ssh_host: text("ServerAddress"), + ssh_port: text("ServerPort"), + ssh_user: text("ServerUser"), + use_private_key: entry.get("isUsePrivateKey").is_some_and(flag), + private_key_name: text("ServerPrivateKeyName"), + tls_mode: entry + .contains_key("tLSMode") + .then(|| entry.get("tLSMode").and_then(Value::as_signed_integer).unwrap_or(0)), + tls_key_paths: entry.get("TlsKeyPaths").map(strings).unwrap_or_default(), + tls_unread: false, + environment: text("Enviroment"), + database_password_mode: entry.get("DatabasePasswordMode").and_then(Value::as_signed_integer), + server_password_mode: entry.get("ServerPasswordMode").and_then(Value::as_signed_integer), + } + } + + pub(super) fn database_password_mode(&self) -> PasswordMode { + PasswordMode::from_index(self.database_password_mode) + } + + pub(super) fn server_password_mode(&self) -> PasswordMode { + PasswordMode::from_index(self.server_password_mode) + } +} + +fn port(value: &Value) -> Option { + match value { + Value::Integer(_) => value.as_signed_integer(), + Value::String(text) => text.parse().ok(), + _ => None, + } +} + +// The macOS app saves a Bool; the Windows app saves 0 or 1. +fn flag(value: &Value) -> bool { + match value { + Value::Boolean(on) => *on, + Value::Integer(_) => value.as_signed_integer() == Some(1), + _ => false, + } +} + +// All or nothing, as a list with one stray value reads as no list. +fn strings(value: &Value) -> Vec { + value + .as_array() + .and_then(|items| { + items + .iter() + .map(|item| item.as_string().map(str::to_owned)) + .collect::>>() + }) + .unwrap_or_default() +} + +pub(super) struct Group { + pub name: String, + pub parent: Option, +} + +pub(super) fn groups_from_plist(root: &Value) -> HashMap { + let Some(items) = root.as_array() else { + return HashMap::new(); + }; + let Some(dictionaries) = items.iter().map(Value::as_dictionary).collect::>>() else { + return HashMap::new(); + }; + let mut groups = HashMap::with_capacity(dictionaries.len()); + for group in dictionaries { + let text = |key: &str| group.get(key).and_then(Value::as_string).map(str::to_owned); + if let (Some(id), Some(name)) = (text("ID"), text("Name")) { + groups.insert( + id, + Group { + name, + parent: text("GroupID"), + }, + ); + } + } + groups +} + +// Walks up through each group's own parent, root first. +pub(super) fn group_path(group_id: Option<&str>, groups: &HashMap) -> Vec { + let mut path = Vec::new(); + let mut seen = HashSet::new(); + let mut next = group_id.filter(|id| !id.is_empty()); + while let Some(id) = next { + if !seen.insert(id) { + break; + } + let Some(group) = groups.get(id) else { + break; + }; + path.push(group.name.clone()); + next = group.parent.as_deref().filter(|parent| !parent.is_empty()); + } + path.reverse(); + path.truncate(MAXIMUM_GROUP_DEPTH); + path +} + +pub(super) struct Mapping<'a> { + pub dialect: Dialect, + pub dirs: &'a ForeignAppDirs, + pub key_file_exists: fn(&Path) -> bool, +} + +impl Mapping<'_> { + // `None` for an entry with no name, which the app never writes. + pub(super) fn connection(&self, entry: &Entry) -> Option { + let name = entry.name.clone()?; + let driver = self.driver(entry.driver.as_deref().unwrap_or_default()); + let engine = engine_for_driver(driver); + let file_path_field = engine::local_file_path_field(&engine); + // The Windows app keeps a file engine's path where the host goes. + let path_in_host = + self.dialect == Dialect::Windows && file_path_field.is_some() && entry.database_path.is_none(); + let raw_path = if path_in_host { + &entry.host + } else { + &entry.database_path + }; + let file_path = file_path_field.and(raw_path.as_deref()).filter(|path| !path.is_empty()); + + let host = if path_in_host { None } else { entry.host.clone() }; + let port = match entry.port.and_then(|port| u16::try_from(port).ok()) { + Some(port) => port, + None if file_path.is_none() => engine::default_port(&engine), + None => 0, + }; + + let mut additional_fields = BTreeMap::new(); + let database = match (file_path, file_path_field) { + (Some(path), Some(LocalFilePathField::Database)) => path.to_owned(), + (Some(path), Some(LocalFilePathField::AdditionalField(field))) => { + additional_fields.insert(field.to_owned(), path.to_owned()); + String::new() + } + _ => entry.database_name.clone().unwrap_or_default(), + }; + if engine == "DynamoDB" { + additional_fields.extend(dynamodb_fields(entry)); + } + if entry.database_password_mode().prompts_for_password() + && !engine::hides_built_in_password(&engine, &additional_fields) + { + additional_fields.insert("promptForPassword".to_owned(), "true".to_owned()); + } + if entry.tls_unread { + additional_fields.insert(crate::TLS_SETTINGS_UNREAD.to_owned(), "true".to_owned()); + } + + Some(ForeignConnection { + name, + host: host.unwrap_or_else(|| "localhost".to_owned()), + port, + database, + username: entry.user.clone().unwrap_or_default(), + ssh: self.ssh(entry), + ssl: ssl(entry, driver), + color: entry.environment.as_deref().and_then(environment_color), + engine, + additional_fields, + }) + } + + // The Windows app reads its driver names in any case. + fn driver<'a>(&self, driver: &'a str) -> &'a str { + if self.dialect != Dialect::Windows { + return driver; + } + KNOWN_DRIVERS + .iter() + .find(|known| known.eq_ignore_ascii_case(driver)) + .copied() + .unwrap_or(driver) + } + + fn ssh(&self, entry: &Entry) -> Option { + if !entry.over_ssh { + return None; + } + let key_path = if entry.use_private_key { + self.imported_key_path(entry.private_key_name.as_deref().unwrap_or_default()) + } else { + String::new() + }; + Some(ForeignSsh { + host: entry.ssh_host.clone().unwrap_or_default(), + port: entry + .ssh_port + .as_deref() + .and_then(|port| port.parse::().ok()) + .and_then(|port| u16::try_from(port).ok()), + username: entry.ssh_user.clone().unwrap_or_default(), + auth: if entry.use_private_key { + ForeignSshAuth::PrivateKey + } else { + ForeignSshAuth::Password + }, + private_key_path: key_path, + jump_hosts: Vec::new(), + }) + } + + // A bare name is a key in `~/.ssh`, kept only when it is there: the app's empty picker + // saves its placeholder text as the name. + fn imported_key_path(&self, raw: &str) -> String { + let trimmed = raw.trim_matches(is_inline_space); + let resolved = resolve_key_path(trimmed); + if resolved.is_empty() || resolved == trimmed { + return resolved; + } + if (self.key_file_exists)(&self.dirs.expand_home(&resolved)) { + resolved + } else { + String::new() + } + } +} + +const KNOWN_DRIVERS: &[&str] = &[ + "MicrosoftSQLServer", + "Mongo", + "Cockroach", + "CloudflareD1", + "LibSQL", + "ElasticSearch", + "MySQL", + "MariaDB", + "PostgreSQL", + "Greenplum", + "Redshift", + "Vertica", + "Cassandra", + "Redis", + "ClickHouse", + "Oracle", +]; + +fn engine_for_driver(driver: &str) -> String { + match driver { + "MicrosoftSQLServer" => "SQL Server".to_owned(), + "Mongo" => "MongoDB".to_owned(), + "Cockroach" => "CockroachDB".to_owned(), + "CloudflareD1" => "Cloudflare D1".to_owned(), + "LibSQL" => "libSQL".to_owned(), + "ElasticSearch" => "Elasticsearch".to_owned(), + _ => engine::resolve(driver), + } +} + +fn ssl(entry: &Entry, driver: &str) -> Option { + let form = tls_form(driver); + let mode = *form.modes.get(usize::try_from(entry.tls_mode?).ok()?)?; + let path = |slot: Option| { + slot.and_then(|slot| entry.tls_key_paths.get(slot)) + .filter(|path| !path.is_empty()) + .cloned() + }; + Some(ForeignSsl { + mode, + ca_certificate_path: path(form.slots.certificate_authority), + client_certificate_path: path(form.slots.client_certificate), + client_key_path: path(form.slots.client_key), + }) +} + +// DynamoDB keeps its region in the host field and its access key ID in the user field. +fn dynamodb_fields(entry: &Entry) -> BTreeMap { + let mut fields = BTreeMap::from([("awsAuthMethod".to_owned(), "credentials".to_owned())]); + let trimmed = |value: &Option| { + value + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) + }; + if let Some(region) = trimmed(&entry.host) { + fields.insert("awsRegion".to_owned(), region); + } + if let Some(access_key_id) = trimmed(&entry.user) { + fields.insert("awsAccessKeyId".to_owned(), access_key_id); + } + fields +} + +fn environment_color(environment: &str) -> Option { + match environment { + "staging" => Some(ForeignColor::Yellow), + "production" => Some(ForeignColor::Red), + "testing" => Some(ForeignColor::Blue), + "development" => Some(ForeignColor::Green), + _ => None, + } +} + +// Spaces and tabs, not line breaks. +fn is_inline_space(character: char) -> bool { + character == '\t' + || (character.is_whitespace() + && !matches!( + character, + '\n' | '\r' | '\u{0B}' | '\u{0C}' | '\u{85}' | '\u{2028}' | '\u{2029}' + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ForeignPlatform, ForeignSslMode}; + + fn groups(items: &[(&str, &str, Option<&str>)]) -> HashMap { + items + .iter() + .map(|(id, name, parent)| { + ( + (*id).to_owned(), + Group { + name: (*name).to_owned(), + parent: parent.map(str::to_owned), + }, + ) + }) + .collect() + } + + fn mapping(dirs: &ForeignAppDirs, dialect: Dialect, key_file_exists: fn(&Path) -> bool) -> Mapping<'_> { + Mapping { + dialect, + dirs, + key_file_exists, + } + } + + fn named(name: &str) -> Entry { + Entry { + name: Some(name.to_owned()), + ..Entry::default() + } + } + + #[test] + fn a_group_path_walks_parents_root_first_and_stops_at_a_cycle_or_three_levels() { + let map = groups(&[ + ("a", "Servers", None), + ("b", "EU", Some("a")), + ("c", "Prod", Some("b")), + ("d", "Deep", Some("c")), + ("x", "Loop X", Some("y")), + ("y", "Loop Y", Some("x")), + ("orphan", "Orphan", Some("gone")), + ]); + assert_eq!(group_path(Some("b"), &map), ["Servers", "EU"]); + assert_eq!(group_path(Some("d"), &map), ["Servers", "EU", "Prod"]); + assert_eq!(group_path(Some("x"), &map), ["Loop Y", "Loop X"]); + assert_eq!(group_path(Some("orphan"), &map), ["Orphan"]); + assert!(group_path(Some("missing"), &map).is_empty()); + assert!(group_path(Some(""), &map).is_empty()); + assert!(group_path(None, &map).is_empty()); + } + + #[test] + fn groups_read_as_none_when_one_item_is_not_a_dictionary() { + let mut group = Dictionary::new(); + group.insert("ID".to_owned(), Value::String("g".to_owned())); + group.insert("Name".to_owned(), Value::String("Prod".to_owned())); + let good = Value::Array(vec![Value::Dictionary(group.clone())]); + let mixed = Value::Array(vec![Value::Dictionary(group), Value::String("stray".to_owned())]); + assert_eq!(groups_from_plist(&good).len(), 1); + assert!(groups_from_plist(&mixed).is_empty()); + } + + #[test] + fn flags_and_ports_accept_the_types_each_app_writes() { + assert!(flag(&Value::Boolean(true))); + assert!(flag(&Value::Integer(1_i64.into()))); + assert!(!flag(&Value::Integer(2_i64.into()))); + assert!(!flag(&Value::String("true".to_owned()))); + assert_eq!(port(&Value::Integer(5433_i64.into())), Some(5433)); + assert_eq!(port(&Value::String("5433".to_owned())), Some(5433)); + assert_eq!(port(&Value::String(" 5433".to_owned())), None); + assert_eq!(port(&Value::String(String::new())), None); + let mixed = Value::Array(vec![Value::String("a".to_owned()), Value::Integer(1_i64.into())]); + assert!(strings(&mixed).is_empty()); + } + + #[test] + fn a_bare_key_name_is_checked_in_the_ssh_folder_and_an_explicit_path_is_kept() { + let dirs = ForeignAppDirs::conventional(ForeignPlatform::MacOs, Path::new("/Users/test")); + let present = mapping(&dirs, Dialect::MacOs, |path| { + path == Path::new("/Users/test/.ssh/id_rsa") + }); + assert_eq!(present.imported_key_path(" id_rsa\t"), "~/.ssh/id_rsa"); + assert_eq!(present.imported_key_path("id_other"), ""); + assert_eq!(present.imported_key_path("/keys/id"), "/keys/id"); + assert_eq!(present.imported_key_path("~/keys/id"), "~/keys/id"); + assert_eq!(present.imported_key_path("C:\\keys\\id"), "C:\\keys\\id"); + assert_eq!(present.imported_key_path(" "), ""); + } + + #[test] + fn a_windows_file_engine_reads_its_path_from_the_host_and_its_driver_in_any_case() { + let dirs = ForeignAppDirs::conventional(ForeignPlatform::Windows, Path::new("C:/Users/test")); + let windows = mapping(&dirs, Dialect::Windows, |_| false); + let sqlite = Entry { + driver: Some("sqlite".to_owned()), + host: Some("C:\\data\\app.db".to_owned()), + ..named("Local") + }; + let connection = windows.connection(&sqlite).expect("a connection"); + assert_eq!(connection.engine, "SQLite"); + assert_eq!(connection.database, "C:\\data\\app.db"); + assert_eq!(connection.host, "localhost"); + assert_eq!(connection.port, 0); + + let postgres = Entry { + driver: Some("postgresql".to_owned()), + tls_mode: Some(4), + ..named("Server") + }; + let connection = windows.connection(&postgres).expect("a connection"); + assert_eq!(connection.engine, "PostgreSQL"); + assert_eq!(connection.ssl.map(|ssl| ssl.mode), Some(ForeignSslMode::VerifyCa)); + + let macos = mapping(&dirs, Dialect::MacOs, |_| false); + let connection = macos.connection(&sqlite).expect("a connection"); + assert_eq!(connection.database, ""); + assert_eq!(connection.host, "C:\\data\\app.db"); + } + + #[test] + fn a_port_past_the_range_falls_back_to_the_engines_default() { + let dirs = ForeignAppDirs::conventional(ForeignPlatform::MacOs, Path::new("/Users/test")); + let entry = Entry { + driver: Some("MySQL".to_owned()), + port: Some(70_000), + ..named("Wide") + }; + let connection = mapping(&dirs, Dialect::MacOs, |_| false) + .connection(&entry) + .expect("a connection"); + assert_eq!(connection.port, 3306); + assert!( + mapping(&dirs, Dialect::MacOs, |_| false) + .connection(&Entry::default()) + .is_none() + ); + } +} diff --git a/core/crates/foreign-import/src/apps/tableplus/favorites.rs b/core/crates/foreign-import/src/apps/tableplus/favorites.rs new file mode 100644 index 0000000000..b795bcab08 --- /dev/null +++ b/core/crates/foreign-import/src/apps/tableplus/favorites.rs @@ -0,0 +1,204 @@ +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +use tokio_util::sync::CancellationToken; + +use crate::app::check_cancel; +use crate::sql_file_tree::{self, SqlFileEntry}; +use crate::{ForeignImportError, ForeignSavedQuery}; + +const KEYWORD_FILE_SUFFIX: &str = ".tag"; +const MAXIMUM_KEYWORD_FILE_BYTE_COUNT: u64 = 4_096; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Root { + pub path: PathBuf, + pub folder_path: Vec, +} + +// The app's own folder first, then each shared folder under its own name. +pub(super) fn roots(favorite_root: PathBuf, shared_folders: Vec) -> Vec { + let mut seen = HashSet::from([standardized(&favorite_root)]); + let mut roots = vec![Root { + path: favorite_root, + folder_path: Vec::new(), + }]; + for folder in shared_folders { + if !seen.insert(standardized(&folder)) { + continue; + } + let name = folder + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + roots.push(Root { + path: folder, + folder_path: vec![name], + }); + } + roots +} + +pub(super) fn saved_queries( + roots: &[Root], + limit: u64, + reads_keywords: bool, + cancel: &CancellationToken, +) -> Result, ForeignImportError> { + let mut queries = Vec::new(); + for root in roots { + for entry in entries(&root.path, cancel) { + check_cancel(cancel)?; + let Some(content) = sql_file_tree::content(&entry, limit) else { + continue; + }; + queries.push(ForeignSavedQuery { + name: entry.stem(), + content, + keyword: if reads_keywords { keyword(&entry) } else { None }, + folder_path: root.folder_path.iter().chain(&entry.folder_path).cloned().collect(), + source_connection_id: None, + is_auto_named: false, + }); + } + } + check_cancel(cancel)?; + Ok(queries) +} + +pub(super) fn count(roots: &[Root]) -> usize { + let cancel = CancellationToken::new(); + roots + .iter() + .map(|root| { + entries(&root.path, &cancel) + .iter() + .filter(|entry| sql_file_tree::is_countable(entry)) + .count() + }) + .sum() +} + +fn entries(root: &Path, cancel: &CancellationToken) -> Vec { + sql_file_tree::entries(root, &|name| !name.ends_with(KEYWORD_FILE_SUFFIX), cancel) +} + +// A favorite holds a comma-separated list; a saved query keeps one keyword, and it cannot hold a space. +fn keyword(entry: &SqlFileEntry) -> Option { + let tag_path = entry + .path + .with_file_name(format!("{}{KEYWORD_FILE_SUFFIX}", entry.file_name)); + // A link or a pipe beside a favorite is never opened. + if !fs::symlink_metadata(&tag_path).ok()?.is_file() { + return None; + } + let mut data = Vec::new(); + File::open(&tag_path) + .ok()? + .take(MAXIMUM_KEYWORD_FILE_BYTE_COUNT) + .read_to_end(&mut data) + .ok()?; + let list = std::str::from_utf8(&data).ok()?; + list.split(',') + .map(str::trim) + .find(|keyword| !keyword.is_empty() && !keyword.chars().any(char::is_whitespace)) + .map(str::to_owned) +} + +// A path with `.` and `..` folded away, without touching the disk. +pub(super) fn standardized(path: &Path) -> PathBuf { + let mut folded = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + folded.pop(); + } + other => folded.push(other), + } + } + folded +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("a folder"); + } + fs::write(path, contents).expect("a file"); + } + + fn entry_for(path: &Path) -> SqlFileEntry { + SqlFileEntry { + path: path.to_path_buf(), + folder_path: Vec::new(), + file_name: path.file_name().expect("a name").to_string_lossy().into_owned(), + byte_count: 0, + } + } + + #[test] + fn a_shared_folder_already_listed_is_not_listed_twice() { + let listed = roots( + PathBuf::from("/q/Favorite"), + vec![ + PathBuf::from("/q/./Favorite/"), + PathBuf::from("/team/queries"), + PathBuf::from("/team/x/../queries"), + ], + ); + assert_eq!( + listed, + [ + Root { + path: PathBuf::from("/q/Favorite"), + folder_path: Vec::new() + }, + Root { + path: PathBuf::from("/team/queries"), + folder_path: vec!["queries".to_owned()] + }, + ] + ); + } + + #[test] + fn the_first_keyword_without_a_space_wins() { + let root = tempfile::tempdir().expect("a temp dir"); + let query = root.path().join("top.sql"); + write(&query, "SELECT 1"); + write(&root.path().join("top.sql.tag"), " , Tp Space Kw,tpok ,later"); + assert_eq!(keyword(&entry_for(&query)).as_deref(), Some("tpok")); + write(&root.path().join("top.sql.tag"), "kw\u{00A0}nbsp,\n"); + assert_eq!(keyword(&entry_for(&query)), None); + fs::remove_file(root.path().join("top.sql.tag")).expect("removed"); + assert_eq!(keyword(&entry_for(&query)), None); + } + + #[test] + fn a_keyword_file_cut_inside_a_character_reads_as_none() { + let root = tempfile::tempdir().expect("a temp dir"); + let query = root.path().join("q.sql"); + write(&query, "SELECT 1"); + let mut text = "a".repeat(4_095); + text.push('é'); + write(&root.path().join("q.sql.tag"), &text); + assert_eq!(keyword(&entry_for(&query)), None); + } + + #[cfg(unix)] + #[test] + fn a_linked_keyword_file_is_not_read() { + let root = tempfile::tempdir().expect("a temp dir"); + let query = root.path().join("q.sql"); + write(&query, "SELECT 1"); + write(&root.path().join("secret.txt"), "token"); + std::os::unix::fs::symlink(root.path().join("secret.txt"), root.path().join("q.sql.tag")).expect("a link"); + assert_eq!(keyword(&entry_for(&query)), None); + } +} diff --git a/core/crates/foreign-import/src/apps/tableplus/form_values.rs b/core/crates/foreign-import/src/apps/tableplus/form_values.rs new file mode 100644 index 0000000000..d12151e346 --- /dev/null +++ b/core/crates/foreign-import/src/apps/tableplus/form_values.rs @@ -0,0 +1,147 @@ +use crate::ForeignSslMode::{self, Disabled, Preferred, Required, VerifyCa, VerifyIdentity}; + +// The index of TablePlus's password popup, as it saves it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PasswordMode { + StoreInKeychain, + AskEveryTime, + NoPassword, + CommandLine, +} + +impl PasswordMode { + pub(super) fn from_index(index: Option) -> Self { + match index { + Some(1) => Self::AskEveryTime, + Some(2) => Self::NoPassword, + Some(3) => Self::CommandLine, + _ => Self::StoreInKeychain, + } + } + + pub(super) fn stores_password_in_keychain(self) -> bool { + self == Self::StoreInKeychain + } + + pub(super) fn prompts_for_password(self) -> bool { + matches!(self, Self::AskEveryTime | Self::CommandLine) + } +} + +// Which entry of `TlsKeyPaths` each file picker of a driver's form writes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct TlsKeySlots { + pub client_key: Option, + pub client_certificate: Option, + pub certificate_authority: Option, +} + +const KEY_CERTIFICATE_AUTHORITY: TlsKeySlots = TlsKeySlots { + client_key: Some(0), + client_certificate: Some(1), + certificate_authority: Some(2), +}; +const CERTIFICATE_KEY_AUTHORITY: TlsKeySlots = TlsKeySlots { + client_key: None, + client_certificate: Some(0), + certificate_authority: Some(1), +}; +const AUTHORITY_ONLY: TlsKeySlots = TlsKeySlots { + client_key: None, + client_certificate: None, + certificate_authority: Some(0), +}; +const NO_SLOTS: TlsKeySlots = TlsKeySlots { + client_key: None, + client_certificate: None, + certificate_authority: None, +}; + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct TlsForm { + // `tLSMode` is an index into the driver's own popup. + pub modes: &'static [ForeignSslMode], + pub slots: TlsKeySlots, +} + +const MYSQL: TlsForm = TlsForm { + modes: &[Preferred, Disabled, Required, VerifyCa, VerifyIdentity], + slots: KEY_CERTIFICATE_AUTHORITY, +}; +const MARIADB: TlsForm = TlsForm { + modes: &[Preferred, Required, VerifyIdentity], + slots: KEY_CERTIFICATE_AUTHORITY, +}; +const POSTGRES: TlsForm = TlsForm { + modes: &[Preferred, Disabled, Required, Preferred, VerifyCa, VerifyIdentity], + slots: KEY_CERTIFICATE_AUTHORITY, +}; +const CASSANDRA: TlsForm = TlsForm { + modes: &[Disabled, Required, VerifyCa, VerifyIdentity, VerifyIdentity], + slots: KEY_CERTIFICATE_AUTHORITY, +}; +const REDIS: TlsForm = TlsForm { + modes: &[Disabled, Required, VerifyCa], + slots: KEY_CERTIFICATE_AUTHORITY, +}; +const CLICKHOUSE: TlsForm = TlsForm { + modes: &[Disabled, VerifyIdentity, Required], + slots: AUTHORITY_ONLY, +}; +const MONGO: TlsForm = TlsForm { + modes: &[Disabled, VerifyIdentity, Required], + slots: CERTIFICATE_KEY_AUTHORITY, +}; +const ELASTICSEARCH: TlsForm = TlsForm { + modes: &[Disabled, VerifyIdentity, VerifyCa, Required], + slots: AUTHORITY_ONLY, +}; +const ORACLE: TlsForm = TlsForm { + modes: &[Disabled, Required], + slots: NO_SLOTS, +}; +const WITHOUT_PICKER: TlsForm = TlsForm { + modes: &[Preferred], + slots: NO_SLOTS, +}; + +pub(super) fn tls_form(driver: &str) -> &'static TlsForm { + match driver { + "MySQL" => &MYSQL, + "MariaDB" => &MARIADB, + "PostgreSQL" | "Cockroach" | "Greenplum" | "Redshift" | "Vertica" => &POSTGRES, + "Cassandra" => &CASSANDRA, + "Redis" => &REDIS, + "ClickHouse" => &CLICKHOUSE, + "Mongo" => &MONGO, + "ElasticSearch" => &ELASTICSEARCH, + "Oracle" => &ORACLE, + _ => &WITHOUT_PICKER, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_unknown_password_mode_index_means_the_keychain() { + assert_eq!(PasswordMode::from_index(None), PasswordMode::StoreInKeychain); + assert_eq!(PasswordMode::from_index(Some(47)), PasswordMode::StoreInKeychain); + assert_eq!(PasswordMode::from_index(Some(-1)), PasswordMode::StoreInKeychain); + assert_eq!(PasswordMode::from_index(Some(3)), PasswordMode::CommandLine); + assert!(PasswordMode::AskEveryTime.prompts_for_password()); + assert!(PasswordMode::CommandLine.prompts_for_password()); + assert!(!PasswordMode::NoPassword.prompts_for_password()); + assert!(!PasswordMode::NoPassword.stores_password_in_keychain()); + } + + #[test] + fn a_driver_without_its_own_popup_offers_preferred_only() { + assert_eq!(tls_form("MicrosoftSQLServer"), &WITHOUT_PICKER); + assert_eq!(tls_form("Snowflake").modes, &[Preferred]); + assert_eq!(tls_form("mysql"), &WITHOUT_PICKER); + assert_eq!(tls_form("Greenplum"), &POSTGRES); + assert_eq!(tls_form("Mongo").slots, CERTIFICATE_KEY_AUTHORITY); + } +} diff --git a/core/crates/foreign-import/src/apps/tableplus/linux.rs b/core/crates/foreign-import/src/apps/tableplus/linux.rs new file mode 100644 index 0000000000..0128380f94 --- /dev/null +++ b/core/crates/foreign-import/src/apps/tableplus/linux.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; + +use serde::Deserialize; +use serde_json::Value; + +use super::connection::{Entry, Group}; + +// The Linux app saves each list as `{"objects": [...]}`, one serialized object per item with +// kebab-case property names. +#[derive(Deserialize)] +pub(super) struct ObjectList { + #[serde(default)] + pub objects: Vec, +} + +impl ObjectList { + pub(super) fn parse(data: &[u8]) -> Option { + serde_json::from_slice(data).ok() + } + + // All or nothing, as the macOS list of dictionaries. + pub(super) fn all_objects(&self) -> bool { + self.objects.iter().all(Value::is_object) + } +} + +fn text(object: &Value, key: &str) -> Option { + object.get(key).and_then(Value::as_str).map(str::to_owned) +} + +fn integer(object: &Value, key: &str) -> Option { + let value = object.get(key)?; + value + .as_i64() + .or_else(|| value.as_str().and_then(|text| text.parse().ok())) +} + +fn flag(object: &Value, key: &str) -> bool { + object + .get(key) + .is_some_and(|value| value.as_bool().unwrap_or(false) || value.as_i64() == Some(1)) +} + +// The Linux file has no TLS mode, environment or password mode that maps onto the macOS ones: its +// `ssl-mode` numbers and `tag` values are not known, so they stay out. +pub(super) fn entry(object: &Value) -> Entry { + let key_path = text(object, "ssh-key-path").filter(|path| !path.trim().is_empty()); + Entry { + id: text(object, "id"), + name: text(object, "name"), + driver: text(object, "db-driver"), + host: text(object, "db-host"), + port: integer(object, "db-port"), + user: text(object, "db-user"), + database_name: text(object, "db-name"), + database_path: text(object, "db-path"), + group_id: text(object, "group-id"), + over_ssh: flag(object, "use-ssh-tunnel"), + ssh_host: text(object, "ssh-host"), + ssh_port: integer(object, "ssh-port").map(|port| port.to_string()), + ssh_user: text(object, "ssh-user"), + // The app has no key toggle; a saved key path is what picks key sign-in. + use_private_key: key_path.is_some(), + private_key_name: key_path, + tls_mode: None, + tls_key_paths: Vec::new(), + // `-1` is the file's own "no TLS"; any other mode, or a certificate, is TLS not read. + tls_unread: integer(object, "ssl-mode").is_some_and(|mode| mode != -1) + || ["ssl-key-path-1", "ssl-key-path-2", "ssl-key-path-3"] + .iter() + .any(|key| text(object, key).is_some_and(|path| !path.trim().is_empty())), + environment: None, + // Command Line is the one password mode the file records. + database_password_mode: flag(object, "db-use-command-line-password").then_some(3), + server_password_mode: None, + } +} + +pub(super) fn groups(list: &ObjectList) -> HashMap { + if !list.all_objects() { + return HashMap::new(); + } + let mut groups = HashMap::with_capacity(list.objects.len()); + for object in &list.objects { + if let (Some(id), Some(name)) = (text(object, "id"), text(object, "name")) { + groups.insert( + id, + Group { + name, + parent: text(object, "group-id"), + }, + ); + } + } + groups +} + +#[derive(Deserialize, Default)] +pub(super) struct Settings { + #[serde(rename = "shared-folder-paths", default)] + shared_folder_paths: Vec, +} + +impl Settings { + pub(super) fn parse(data: &[u8]) -> Self { + serde_json::from_slice(data).unwrap_or_default() + } + + pub(super) fn shared_folder_paths(&self) -> Vec { + self.shared_folder_paths + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_linux_connection_maps_onto_the_macos_keys() { + let list = ObjectList::parse( + br#"{"objects":[{"id":"l1","name":"Shop","db-driver":"PostgreSQL","db-host":"db","db-port":"5433", + "use-ssh-tunnel":1,"ssh-host":"bastion","ssh-port":"2222","ssh-user":"deploy","ssh-key-path":"/k/id", + "ssl-mode":2,"tag":1,"db-use-command-line-password":true,"group-id":"g1"}],"updated_at":1}"#, + ) + .expect("a list"); + let entry = entry(&list.objects[0]); + assert_eq!(entry.name.as_deref(), Some("Shop")); + assert_eq!(entry.port, Some(5433)); + assert!(entry.over_ssh); + assert!(entry.use_private_key); + assert_eq!(entry.ssh_port.as_deref(), Some("2222")); + assert_eq!(entry.tls_mode, None); + assert_eq!(entry.environment, None); + assert_eq!(entry.database_password_mode, Some(3)); + assert_eq!(entry.group_id.as_deref(), Some("g1")); + } + + #[test] + fn an_empty_key_path_means_password_sign_in() { + let list = + ObjectList::parse(br#"{"objects":[{"name":"A","use-ssh-tunnel":0,"ssh-key-path":" "}]}"#).expect("a list"); + let entry = entry(&list.objects[0]); + assert!(!entry.over_ssh); + assert!(!entry.use_private_key); + assert_eq!(entry.private_key_name, None); + } + + #[test] + fn settings_list_only_text_shared_folders() { + let settings = Settings::parse(br#"{"theme":2,"shared-folder-paths":["/q/team",3,null]}"#); + assert_eq!(settings.shared_folder_paths(), ["/q/team"]); + assert!(Settings::parse(b"not json").shared_folder_paths().is_empty()); + } +} diff --git a/core/crates/foreign-import/src/apps/tableplus/locations.rs b/core/crates/foreign-import/src/apps/tableplus/locations.rs new file mode 100644 index 0000000000..88d8daa325 --- /dev/null +++ b/core/crates/foreign-import/src/apps/tableplus/locations.rs @@ -0,0 +1,344 @@ +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use super::connection::Dialect; +use super::favorites::{self, Root, standardized}; +use super::linux::Settings; +use crate::{ForeignAppDirs, ForeignPlatform}; + +// The standalone app first: it wins when both editions are there. +pub(super) const BUNDLE_IDENTIFIERS: [&str; 2] = ["com.tinyapp.TablePlus", "com.tinyapp.TablePlus-setapp"]; +const WINDOWS_FOLDER: &str = "com.tinyapp.TablePlus"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum StoreFormat { + Plist, + Json, +} + +// A folder holding one connection list and its groups. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ConnectionStore { + pub folder: PathBuf, + pub format: StoreFormat, +} + +impl ConnectionStore { + fn plist(folder: PathBuf) -> Self { + Self { + folder, + format: StoreFormat::Plist, + } + } + + pub(super) fn connections_file(&self) -> PathBuf { + self.folder.join(match self.format { + StoreFormat::Plist => "Connections.plist", + StoreFormat::Json => "connections.json", + }) + } + + pub(super) fn groups_file(&self) -> PathBuf { + self.folder.join(match self.format { + StoreFormat::Plist => "ConnectionGroups.plist", + StoreFormat::Json => "connection_groups.json", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Layout { + pub dialect: Dialect, + pub stores: Vec, + pub favorite_roots: Vec, + pub reads_keywords: bool, +} + +pub(super) fn layout(dirs: &ForeignAppDirs) -> Layout { + match dirs.platform { + ForeignPlatform::MacOs => macos(dirs), + ForeignPlatform::Windows => windows(dirs), + ForeignPlatform::Linux => linux(dirs), + } +} + +// What the app keeps in its preferences about where its files are. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct ViewSetting { + pub shared_connection_path: Option, + pub shared_query_path: Option, + pub favorites: Vec, +} + +// Moving the data in the app's preferences writes the connection files directly under the chosen folder. +fn macos(dirs: &ForeignAppDirs) -> Layout { + let bundle = macos_bundle_identifier(dirs); + let data = macos_data_directory(&dirs.data, bundle); + let setting = macos_view_setting(&macos_preferences_file(&dirs.home, bundle)).unwrap_or_default(); + let connections = shared_path(setting.shared_connection_path.as_deref(), dirs).unwrap_or_else(|| data.clone()); + let favorite_root = shared_path(setting.shared_query_path.as_deref(), dirs) + .unwrap_or(data) + .join("Favorite"); + Layout { + dialect: Dialect::MacOs, + stores: vec![ConnectionStore::plist(connections)], + favorite_roots: favorites::roots(favorite_root, shared_folders(&setting.favorites, dirs)), + reads_keywords: true, + } +} + +// The edition whose files are on disk, as no installed-app lookup reaches this crate. +pub(super) fn macos_bundle_identifier(dirs: &ForeignAppDirs) -> &'static str { + BUNDLE_IDENTIFIERS + .into_iter() + .find(|bundle| dirs.data.join(bundle).is_dir() || macos_preferences_file(&dirs.home, bundle).is_file()) + .unwrap_or(BUNDLE_IDENTIFIERS[0]) +} + +pub(super) fn macos_data_directory(application_support: &Path, bundle: &str) -> PathBuf { + application_support.join(bundle).join("Data") +} + +fn macos_preferences_file(home: &Path, bundle: &str) -> PathBuf { + home.join("Library").join("Preferences").join(format!("{bundle}.plist")) +} + +pub(super) fn macos_view_setting(preferences: &Path) -> Option { + let data = crate::files::read_capped(preferences).ok()?; + let root = plist::Value::from_reader(std::io::Cursor::new(data)).ok()?; + let setting = root.as_dictionary()?.get("ViewSetting")?.as_dictionary()?; + let text = |key: &str| setting.get(key).and_then(plist::Value::as_string).map(str::to_owned); + let favorites = setting + .get("Favorites") + .and_then(plist::Value::as_array) + .and_then(|items| { + items + .iter() + .map(plist::Value::as_dictionary) + .collect::>>() + }) + .unwrap_or_default() + .into_iter() + .filter_map(|favorite| { + favorite + .get("path") + .and_then(plist::Value::as_string) + .map(str::to_owned) + }) + .collect(); + Some(ViewSetting { + shared_connection_path: text("SharedConnectionPath"), + shared_query_path: text("SharedQueryPath"), + favorites, + }) +} + +// The local folder, then every extra folder the app lists, each with its own connection files. +fn windows(dirs: &ForeignAppDirs) -> Layout { + let base = dirs.data.join(WINDOWS_FOLDER); + let data = base.join("Data"); + let mut stores = vec![ConnectionStore::plist(data.clone())]; + for folder in windows_connection_sources(&base.join("ConnectionSources.json"), dirs) { + let folder_key = standardized(&folder); + if stores.iter().all(|store| standardized(&store.folder) != folder_key) { + stores.push(ConnectionStore::plist(folder)); + } + } + let setting = windows_setting(&base.join("Setting.json")); + let favorite_root = shared_path(setting.shared_query_path.as_deref(), dirs) + .unwrap_or(data) + .join("Favorite"); + Layout { + dialect: Dialect::Windows, + stores, + favorite_roots: favorites::roots(favorite_root, shared_folders(&setting.favorites, dirs)), + reads_keywords: true, + } +} + +#[derive(Deserialize)] +struct SourceList { + #[serde(default)] + sources: Vec, +} + +fn windows_connection_sources(path: &Path, dirs: &ForeignAppDirs) -> Vec { + let Some(list) = crate::files::read_capped(path) + .ok() + .and_then(|data| serde_json::from_slice::(&data).ok()) + else { + return Vec::new(); + }; + list.sources + .iter() + .filter_map(|source| source.get("path").and_then(serde_json::Value::as_str)) + .filter_map(|path| shared_path(Some(path), dirs)) + .filter(|folder| folder.is_dir()) + .collect() +} + +#[derive(Deserialize, Default)] +struct WindowsSetting { + #[serde(rename = "sharedQueryPath", default)] + shared_query_path: Option, + #[serde(default)] + favorites: Option, +} + +fn windows_setting(path: &Path) -> ViewSetting { + let setting: WindowsSetting = crate::files::read_capped(path) + .ok() + .and_then(|data| serde_json::from_slice(&data).ok()) + .unwrap_or_default(); + let favorites = setting + .favorites + .as_ref() + .and_then(serde_json::Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.get("path").and_then(serde_json::Value::as_str)) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + ViewSetting { + shared_connection_path: None, + shared_query_path: setting + .shared_query_path + .as_ref() + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + favorites, + } +} + +// The Linux app keeps everything under `~/.tableplus`, not under the XDG folders. +fn linux(dirs: &ForeignAppDirs) -> Layout { + let base = dirs.home.join(".tableplus"); + let settings_folder = base.join("settings"); + let settings = crate::files::read_capped(settings_folder.join("settings.json")) + .map(|data| Settings::parse(&data)) + .unwrap_or_default(); + Layout { + dialect: Dialect::Linux, + stores: vec![ConnectionStore { + folder: settings_folder, + format: StoreFormat::Json, + }], + favorite_roots: favorites::roots( + base.join("queries"), + shared_folders(&settings.shared_folder_paths(), dirs), + ), + reads_keywords: false, + } +} + +// A folder the user picked in the app, `~` meaning the home folder. +fn shared_path(raw: Option<&str>, dirs: &ForeignAppDirs) -> Option { + let path = raw?.trim(); + if path.is_empty() { + return None; + } + let expanded = if path == "~" { + dirs.home.clone() + } else { + dirs.expand_home(path) + }; + expanded.is_absolute().then_some(expanded) +} + +fn shared_folders(raw_paths: &[String], dirs: &ForeignAppDirs) -> Vec { + raw_paths + .iter() + .filter_map(|path| shared_path(Some(path), dirs)) + .filter(|folder| folder.is_dir()) + .collect() +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + fn dirs(platform: ForeignPlatform, home: &Path) -> ForeignAppDirs { + ForeignAppDirs::conventional(platform, home) + } + + #[test] + fn the_standalone_edition_wins_and_setapp_is_read_when_only_it_is_there() { + let home = tempfile::tempdir().expect("a temp dir"); + let macos = dirs(ForeignPlatform::MacOs, home.path()); + assert_eq!(macos_bundle_identifier(&macos), "com.tinyapp.TablePlus"); + + fs::create_dir_all(macos.data.join("com.tinyapp.TablePlus-setapp")).expect("a folder"); + assert_eq!(macos_bundle_identifier(&macos), "com.tinyapp.TablePlus-setapp"); + + let preferences = home.path().join("Library/Preferences"); + fs::create_dir_all(&preferences).expect("a folder"); + fs::write(preferences.join("com.tinyapp.TablePlus.plist"), b"").expect("a file"); + assert_eq!(macos_bundle_identifier(&macos), "com.tinyapp.TablePlus"); + } + + #[test] + fn the_data_directory_follows_the_bundle_identifier() { + let support = Path::new("/Users/test/Library/Application Support"); + assert_eq!( + macos_data_directory(support, "com.tinyapp.TablePlus-setapp"), + Path::new("/Users/test/Library/Application Support/com.tinyapp.TablePlus-setapp/Data") + ); + } + + #[test] + fn view_setting_is_none_without_the_file_or_the_key() { + let home = tempfile::tempdir().expect("a temp dir"); + assert_eq!(macos_view_setting(&home.path().join("missing.plist")), None); + let path = home.path().join("other.plist"); + plist::Value::Dictionary(plist::Dictionary::new()) + .to_file_xml(&path) + .expect("a plist"); + assert_eq!(macos_view_setting(&path), None); + } + + #[test] + fn a_shared_path_is_trimmed_expanded_and_must_be_absolute() { + // Absolute as this host reads a path, so the test holds on Windows too. + let (home, team) = if cfg!(windows) { + (r"C:\Users\test", r"D:\Team") + } else { + ("/Users/test", "/Volumes/Team") + }; + let macos = dirs(ForeignPlatform::MacOs, Path::new(home)); + assert_eq!( + shared_path(Some(" ~/Queries \n"), &macos), + Some(Path::new(home).join("Queries")) + ); + assert_eq!(shared_path(Some("~"), &macos), Some(PathBuf::from(home))); + assert_eq!(shared_path(Some(team), &macos), Some(PathBuf::from(team))); + assert_eq!(shared_path(Some("relative/dir"), &macos), None); + assert_eq!(shared_path(Some(" "), &macos), None); + assert_eq!(shared_path(None, &macos), None); + } + + #[test] + fn a_windows_connection_source_already_listed_is_read_once() { + let home = tempfile::tempdir().expect("a temp dir"); + let windows = dirs(ForeignPlatform::Windows, home.path()); + let base = windows.data.join(WINDOWS_FOLDER); + let team = home.path().join("Team"); + fs::create_dir_all(base.join("Data")).expect("a folder"); + fs::create_dir_all(&team).expect("a folder"); + let quoted = |path: &Path| serde_json::to_string(path).expect("a path"); + let sources = format!( + r#"{{"sources":[{{"id":"1","name":"Local","path":{}}},{{"id":"2","name":"Team","path":{}}},{{"id":"3","path":{}}},{{"id":"4","path":"relative"}},{{"id":"5"}}]}}"#, + quoted(&base.join("Data")), + quoted(&team), + quoted(&home.path().join("gone")), + ); + fs::write(base.join("ConnectionSources.json"), sources).expect("a file"); + let folders: Vec = layout(&windows).stores.into_iter().map(|store| store.folder).collect(); + assert_eq!(folders, [base.join("Data"), team]); + } +} diff --git a/core/crates/foreign-import/src/apps/tableplus/mod.rs b/core/crates/foreign-import/src/apps/tableplus/mod.rs new file mode 100644 index 0000000000..dfdaaec577 --- /dev/null +++ b/core/crates/foreign-import/src/apps/tableplus/mod.rs @@ -0,0 +1,377 @@ +mod connection; +mod favorites; +mod form_values; +mod linux; +mod locations; + +use std::collections::HashMap; +use std::io::Cursor; +use std::path::Path; +use std::sync::Arc; + +use secrecy::SecretString; +use tokio_util::sync::CancellationToken; + +use self::connection::{Dialect, Entry, Group, Mapping, group_path, groups_from_plist}; +use self::linux::ObjectList; +use self::locations::{ConnectionStore, Layout, StoreFormat}; +use crate::app::{AppReader, check_cancel}; +use crate::collect::assemble; +use crate::{ + CollectedImport, ForeignApp, ForeignAppDirs, ForeignConnectionRecord, ForeignCredentials, ForeignImportError, + ForeignKeychain, ImportRequest, Inventory, KeychainRead, PasswordOutcome, +}; + +const KEYCHAIN_SERVICE: &str = "com.tableplus.TablePlus"; + +pub(crate) struct TablePlus { + layout: Layout, + dirs: ForeignAppDirs, + keychain: Option>, + key_file_exists: fn(&Path) -> bool, +} + +impl TablePlus { + fn new( + layout: Layout, + dirs: ForeignAppDirs, + keychain: Option>, + key_file_exists: fn(&Path) -> bool, + ) -> Self { + Self { + layout, + dirs, + keychain, + key_file_exists, + } + } + + pub(crate) fn locate(dirs: &ForeignAppDirs, keychain: Option>) -> Self { + Self::new(locations::layout(dirs), dirs.clone(), keychain, Path::exists) + } + + fn present_stores(&self) -> impl Iterator { + self.layout + .stores + .iter() + .filter(|store| store.connections_file().exists()) + } + + // Only the macOS app keeps its passwords where another app may ask for them. + fn readable_keychain(&self, request: &ImportRequest) -> Option<&dyn ForeignKeychain> { + if !request.include_passwords || self.layout.dialect != Dialect::MacOs { + return None; + } + self.keychain.as_deref() + } +} + +impl AppReader for TablePlus { + fn has_data(&self) -> bool { + self.present_stores().next().is_some() + } + + // Only the entries collect reads, so the count says what an import brings; the macOS app + // counts every entry, unreadable ones included. + fn inventory(&self) -> Inventory { + let mapping = Mapping { + dialect: self.layout.dialect, + dirs: &self.dirs, + key_file_exists: self.key_file_exists, + }; + Inventory { + connections: self + .present_stores() + .map(|store| { + load_entries(store).map_or(0, |entries| { + entries + .iter() + .filter(|entry| mapping.connection(entry).is_some()) + .count() + }) + }) + .sum(), + saved_queries: favorites::count(&self.layout.favorite_roots), + } + } + + fn collect( + &self, + request: &ImportRequest, + cancel: &CancellationToken, + ) -> Result { + let stores: Vec<&ConnectionStore> = self.present_stores().collect(); + if stores.is_empty() { + return Err(ForeignImportError::FileNotFound { + app: ForeignApp::TablePlus, + }); + } + let keychain = self.readable_keychain(request); + let mapping = Mapping { + dialect: self.layout.dialect, + dirs: &self.dirs, + key_file_exists: self.key_file_exists, + }; + let mut stopped = false; + let mut records = Vec::new(); + for store in stores { + let entries = load_entries(store)?; + let groups = load_groups(store); + for entry in entries { + check_cancel(cancel)?; + let Some(settings) = mapping.connection(&entry) else { + tracing::warn!("skipping a TablePlus connection that could not be read"); + continue; + }; + let credentials = match (keychain, entry.id.as_deref()) { + (Some(keychain), Some(id)) if !stopped => read_credentials(keychain, id, &entry, &mut stopped), + _ => None, + }; + records.push(ForeignConnectionRecord { + group_path: group_path(entry.group_id.as_deref(), &groups), + source_id: entry.id, + settings, + credentials, + }); + } + } + + let saved_queries = if request.include_saved_queries { + favorites::saved_queries( + &self.layout.favorite_roots, + request.saved_query_byte_limit, + self.layout.reads_keywords, + cancel, + )? + } else { + Vec::new() + }; + + let passwords = match (request.include_passwords, keychain) { + (false, _) => PasswordOutcome::NotRequested, + (true, None) => PasswordOutcome::LeftInKeychain, + (true, Some(_)) if stopped => PasswordOutcome::Stopped, + (true, Some(_)) => PasswordOutcome::Read, + }; + assemble(ForeignApp::TablePlus, records, saved_queries, passwords) + } +} + +fn load_entries(store: &ConnectionStore) -> Result, ForeignImportError> { + let path = store.connections_file(); + let data = crate::files::read_capped(&path).map_err(|source| ForeignImportError::Unreadable { path, source })?; + match store.format { + StoreFormat::Plist => { + let root = plist::Value::from_reader(Cursor::new(data)).ok(); + let dictionaries = root + .as_ref() + .and_then(plist::Value::as_array) + .and_then(|items| { + items + .iter() + .map(plist::Value::as_dictionary) + .collect::>>() + }) + .ok_or_else(|| { + ForeignImportError::unsupported("Expected array of dictionaries in Connections.plist") + })?; + Ok(dictionaries.into_iter().map(Entry::from_plist).collect()) + } + StoreFormat::Json => { + let list = ObjectList::parse(&data) + .filter(ObjectList::all_objects) + .ok_or_else(|| ForeignImportError::unsupported("Expected a list of objects in connections.json"))?; + Ok(list.objects.iter().map(linux::entry).collect()) + } + } +} + +fn load_groups(store: &ConnectionStore) -> HashMap { + let Ok(data) = crate::files::read_capped(store.groups_file()) else { + return HashMap::new(); + }; + match store.format { + StoreFormat::Plist => plist::Value::from_reader(Cursor::new(data)) + .map(|root| groups_from_plist(&root)) + .unwrap_or_default(), + StoreFormat::Json => ObjectList::parse(&data) + .map(|list| linux::groups(&list)) + .unwrap_or_default(), + } +} + +// A refused prompt stops every read after it, this connection's included. +fn read_credentials( + keychain: &dyn ForeignKeychain, + id: &str, + entry: &Entry, + stopped: &mut bool, +) -> Option { + let mut read = |suffix: &str| -> Option { + if *stopped { + return None; + } + match keychain.read_password(KEYCHAIN_SERVICE, Some(&format!("{id}_{suffix}"))) { + KeychainRead::Found(secret) => Some(secret), + KeychainRead::NotFound => None, + KeychainRead::Denied => { + *stopped = true; + None + } + } + }; + let password = if entry.database_password_mode().stores_password_in_keychain() { + read("database") + } else { + None + }; + let ssh_password = if entry.server_password_mode().stores_password_in_keychain() { + read("server") + } else { + None + }; + let key_passphrase = read("server_key"); + ForeignCredentials { + password, + ssh_password, + key_passphrase, + } + .non_empty() +} + +#[cfg(test)] +mod tests { + use std::fs; + + use std::sync::Mutex; + + use super::*; + use crate::{ForeignPlatform, SavedQueryContent}; + + struct Keychain { + calls: Mutex>, + deny: bool, + } + + impl ForeignKeychain for Keychain { + fn read_password(&self, _service: &str, account: Option<&str>) -> KeychainRead { + if let Ok(mut calls) = self.calls.lock() { + calls.push(account.unwrap_or_default().to_owned()); + } + if self.deny { + KeychainRead::Denied + } else { + KeychainRead::Found(SecretString::from("s3cret")) + } + } + } + + fn write_connections(folder: &Path, names: &[&str]) { + let entries = names + .iter() + .map(|name| { + let mut entry = plist::Dictionary::new(); + entry.insert("ConnectionName".to_owned(), plist::Value::String((*name).to_owned())); + entry.insert("ID".to_owned(), plist::Value::String(format!("id-{name}"))); + plist::Value::Dictionary(entry) + }) + .collect(); + fs::create_dir_all(folder).expect("a folder"); + plist::Value::Array(entries) + .to_file_binary(folder.join("Connections.plist")) + .expect("a plist"); + } + + fn reader(home: &Path, dialect: Dialect, keychain: Arc) -> TablePlus { + let platform = match dialect { + Dialect::MacOs => ForeignPlatform::MacOs, + Dialect::Windows => ForeignPlatform::Windows, + Dialect::Linux => ForeignPlatform::Linux, + }; + let layout = Layout { + dialect, + stores: vec![ConnectionStore { + folder: home.join("Data"), + format: StoreFormat::Plist, + }], + favorite_roots: favorites::roots(home.join("Data/Favorite"), Vec::new()), + reads_keywords: true, + }; + TablePlus::new( + layout, + ForeignAppDirs::conventional(platform, home), + Some(keychain), + |_| false, + ) + } + + fn request(include_passwords: bool) -> ImportRequest { + ImportRequest { + include_passwords, + include_saved_queries: true, + saved_query_byte_limit: 1_000, + } + } + + #[test] + fn a_denied_prompt_stops_every_later_read() { + let home = tempfile::tempdir().expect("a temp dir"); + write_connections(&home.path().join("Data"), &["A", "B"]); + let keychain = Arc::new(Keychain { + calls: Mutex::new(Vec::new()), + deny: true, + }); + let collected = reader(home.path(), Dialect::MacOs, Arc::clone(&keychain)) + .collect(&request(true), &CancellationToken::new()) + .expect("an import"); + assert_eq!(collected.passwords, PasswordOutcome::Stopped); + assert_eq!(*keychain.calls.lock().expect("calls"), ["id-A_database"]); + assert!( + collected + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); + } + + #[test] + fn a_keychain_given_on_another_platform_is_never_asked() { + let home = tempfile::tempdir().expect("a temp dir"); + write_connections(&home.path().join("Data"), &["A"]); + let keychain = Arc::new(Keychain { + calls: Mutex::new(Vec::new()), + deny: false, + }); + let collected = reader(home.path(), Dialect::Windows, Arc::clone(&keychain)) + .collect(&request(true), &CancellationToken::new()) + .expect("an import"); + assert_eq!(collected.passwords, PasswordOutcome::LeftInKeychain); + assert!(keychain.calls.lock().expect("calls").is_empty()); + } + + #[test] + fn a_cancelled_import_stops_with_an_error() { + let home = tempfile::tempdir().expect("a temp dir"); + write_connections(&home.path().join("Data"), &["A"]); + fs::create_dir_all(home.path().join("Data/Favorite")).expect("a folder"); + fs::write(home.path().join("Data/Favorite/q.sql"), "SELECT 1").expect("a file"); + let keychain = Arc::new(Keychain { + calls: Mutex::new(Vec::new()), + deny: false, + }); + let tableplus = reader(home.path(), Dialect::MacOs, keychain); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!(matches!( + tableplus.collect(&request(false), &cancel), + Err(ForeignImportError::Cancelled) + )); + let collected = tableplus + .collect(&request(false), &CancellationToken::new()) + .expect("an import"); + assert_eq!( + collected.saved_queries[0].content, + SavedQueryContent::Text("SELECT 1".to_owned()) + ); + } +} diff --git a/core/crates/foreign-import/src/collect.rs b/core/crates/foreign-import/src/collect.rs new file mode 100644 index 0000000000..5eac21b066 --- /dev/null +++ b/core/crates/foreign-import/src/collect.rs @@ -0,0 +1,215 @@ +use std::collections::{HashMap, HashSet}; + +use crate::{ + ForeignApp, ForeignConnection, ForeignConnectionRecord, ForeignCredentials, ForeignImportError, ForeignSavedQuery, + SavedQueryContent, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PasswordOutcome { + NotRequested, + Read, + // The user refused a keychain prompt, or the app's store was locked, so the rest were not read. + Stopped, + // The app keeps them in a keychain this platform does not let another app read. + LeftInKeychain, +} + +#[derive(Debug, Clone)] +pub struct CollectedImport { + pub app: ForeignApp, + pub connections: Vec, + pub saved_queries: Vec, + pub passwords: PasswordOutcome, +} + +#[derive(Debug, Clone)] +pub struct CollectedConnection { + pub reference: String, + pub settings: ForeignConnection, + pub group_path: Vec, + pub credentials: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CollectedSavedQuery { + pub reference: String, + pub name: String, + pub content: SavedQueryContent, + pub keyword: Option, + // A query with no imported connection sits under a folder named after the app. + pub folder_path: Vec, + pub connection: Option, + pub suggested: bool, +} + +pub(crate) fn assemble( + app: ForeignApp, + connections: Vec, + saved_queries: Vec, + passwords: PasswordOutcome, +) -> Result { + if connections.is_empty() && saved_queries.is_empty() { + return Err(ForeignImportError::NothingFound); + } + + let mut used: HashSet = HashSet::with_capacity(connections.len()); + let mut by_source: HashMap = HashMap::with_capacity(connections.len()); + let mut collected = Vec::with_capacity(connections.len()); + for (index, record) in connections.into_iter().enumerate() { + let source = record.source_id.filter(|id| !id.is_empty()); + let reference = unique_reference( + source.clone().unwrap_or_else(|| format!("row-{index}")), + index, + &mut used, + ); + if let Some(source) = source { + by_source.entry(source).or_insert_with(|| reference.clone()); + } + collected.push(CollectedConnection { + reference, + settings: record.settings, + group_path: record.group_path, + credentials: record.credentials.and_then(ForeignCredentials::non_empty), + }); + } + + let queries = saved_queries + .into_iter() + .enumerate() + .map(|(index, query)| { + let connection = query + .source_connection_id + .as_ref() + .and_then(|id| by_source.get(id)) + .cloned(); + // A query bound to a connection that is not imported still lands, global and unchecked. + let unmapped = query.source_connection_id.is_some() && connection.is_none(); + let folder_path = match connection { + Some(_) => query.folder_path, + None => std::iter::once(app.display_name().to_owned()) + .chain(query.folder_path) + .collect(), + }; + let suggested = match query.content { + SavedQueryContent::Text(_) => !query.is_auto_named && !unmapped, + SavedQueryContent::Oversized { .. } => false, + }; + CollectedSavedQuery { + reference: format!("query-{}", index + 1), + name: query.name, + content: query.content, + keyword: query.keyword, + folder_path, + connection, + suggested, + } + }) + .collect(); + + Ok(CollectedImport { + app, + connections: collected, + saved_queries: queries, + passwords, + }) +} + +fn unique_reference(base: String, index: usize, used: &mut HashSet) -> String { + let mut candidate = base.clone(); + let mut suffix = index; + while !used.insert(candidate.clone()) { + candidate = format!("{base}-{suffix}"); + suffix += 1; + } + candidate +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + fn record(source: Option<&str>, name: &str) -> ForeignConnectionRecord { + ForeignConnectionRecord { + source_id: source.map(str::to_owned), + settings: ForeignConnection { + name: name.to_owned(), + host: "localhost".to_owned(), + port: 5432, + database: String::new(), + username: String::new(), + engine: "PostgreSQL".to_owned(), + ssh: None, + ssl: None, + color: None, + additional_fields: BTreeMap::new(), + }, + group_path: Vec::new(), + credentials: Some(ForeignCredentials::default()), + } + } + + fn query(name: &str, connection: Option<&str>, auto: bool) -> ForeignSavedQuery { + ForeignSavedQuery { + name: name.to_owned(), + content: SavedQueryContent::Text("SELECT 1".to_owned()), + keyword: None, + folder_path: vec!["Reports".to_owned()], + source_connection_id: connection.map(str::to_owned), + is_auto_named: auto, + } + } + + #[test] + fn nothing_to_import_is_an_error() { + let outcome = assemble(ForeignApp::DBeaver, Vec::new(), Vec::new(), PasswordOutcome::Read); + assert!(matches!(outcome, Err(ForeignImportError::NothingFound))); + } + + #[test] + fn references_come_from_source_ids_and_stay_unique() { + let collected = assemble( + ForeignApp::DBeaver, + vec![ + record(Some("a"), "one"), + record(Some("a"), "two"), + record(None, "three"), + record(Some(""), "four"), + ], + Vec::new(), + PasswordOutcome::Read, + ) + .expect("an import"); + let references: Vec<&str> = collected.connections.iter().map(|c| c.reference.as_str()).collect(); + assert_eq!(references, ["a", "a-1", "row-2", "row-3"]); + assert!(collected.connections.iter().all(|c| c.credentials.is_none())); + } + + #[test] + fn queries_follow_their_connection_or_land_in_the_apps_folder_unchecked() { + let collected = assemble( + ForeignApp::DataGrip, + vec![record(Some("uuid-1"), "prod")], + vec![ + query("report", Some("uuid-1"), false), + query("console_2", Some("uuid-1"), true), + query("orphan", Some("gone"), false), + query("global", None, false), + ], + PasswordOutcome::Read, + ) + .expect("an import"); + let queries = &collected.saved_queries; + assert_eq!(queries[0].connection.as_deref(), Some("uuid-1")); + assert_eq!(queries[0].folder_path, ["Reports"]); + assert!(queries[0].suggested); + assert!(!queries[1].suggested); + assert_eq!(queries[2].connection, None); + assert_eq!(queries[2].folder_path, ["DataGrip", "Reports"]); + assert!(!queries[2].suggested); + assert!(queries[3].suggested); + assert_eq!(queries[3].reference, "query-4"); + } +} diff --git a/core/crates/foreign-import/src/dirs.rs b/core/crates/foreign-import/src/dirs.rs new file mode 100644 index 0000000000..60a580944e --- /dev/null +++ b/core/crates/foreign-import/src/dirs.rs @@ -0,0 +1,62 @@ +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ForeignPlatform { + MacOs, + Linux, + Windows, +} + +impl ForeignPlatform { + pub fn current() -> Self { + if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(windows) { + Self::Windows + } else { + Self::Linux + } + } +} + +// Where other apps keep their files. The app fills it from the platform's own directories; a +// sandboxed Linux app passes the host's XDG directories, not its own. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForeignAppDirs { + pub platform: ForeignPlatform, + pub home: PathBuf, + // Linux: $XDG_CONFIG_HOME. Windows: %APPDATA%. macOS: ~/Library/Application Support. + pub config: PathBuf, + // Linux: $XDG_DATA_HOME. Windows: %LOCALAPPDATA%. macOS: ~/Library/Application Support. + pub data: PathBuf, +} + +impl ForeignAppDirs { + pub fn conventional(platform: ForeignPlatform, home: &Path) -> Self { + let (config, data) = match platform { + ForeignPlatform::MacOs => { + let support = home.join("Library").join("Application Support"); + (support.clone(), support) + } + ForeignPlatform::Linux => (home.join(".config"), home.join(".local").join("share")), + ForeignPlatform::Windows => { + let app_data = home.join("AppData"); + (app_data.join("Roaming"), app_data.join("Local")) + } + }; + Self { + platform, + home: home.to_path_buf(), + config, + data, + } + } + + // `~/` as the other app wrote it, against the home folder these directories belong to. + pub fn expand_home(&self, path: &str) -> PathBuf { + match path.strip_prefix("~/") { + Some(rest) => self.home.join(rest), + None => PathBuf::from(path), + } + } +} diff --git a/core/crates/foreign-import/src/engine.rs b/core/crates/foreign-import/src/engine.rs new file mode 100644 index 0000000000..e345a92ea4 --- /dev/null +++ b/core/crates/foreign-import/src/engine.rs @@ -0,0 +1,421 @@ +use std::collections::BTreeMap; + +// Where a file engine's connection keeps the file's path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocalFilePathField { + Database, + AdditionalField(&'static str), +} + +// A connection form field that takes the built-in password's place. +struct PasswordField { + id: &'static str, + shown: Shown, +} + +enum Shown { + // A toggle hides the password while it is on. + Toggle, + // A picker hides it once moved off its default. + Picker { + default: &'static str, + }, + // Any other field hides it while it is on screen. + Always, + When { + field: &'static str, + values: &'static [&'static str], + field_default: &'static str, + }, +} + +struct Engine { + name: &'static str, + default_port: u16, + file_path: Option, + hides_password: bool, + password_fields: &'static [PasswordField], +} + +const AWS_PICKER: PasswordField = PasswordField { + id: "awsAuth", + shown: Shown::Picker { default: "off" }, +}; +const PGPASS: PasswordField = PasswordField { + id: "usePgpass", + shown: Shown::Toggle, +}; +const LIBSQL_MODE: PasswordField = PasswordField { + id: "libsqlMode", + shown: Shown::Picker { default: "remote" }, +}; +const TRINO_TOKEN: PasswordField = PasswordField { + id: "trinoJwtToken", + shown: Shown::When { + field: "trinoAuthMethod", + values: &["jwt"], + field_default: "password", + }, +}; + +const fn server(name: &'static str, default_port: u16) -> Engine { + Engine { + name, + default_port, + file_path: None, + hides_password: false, + password_fields: &[], + } +} + +const fn signs_in(name: &'static str, default_port: u16, password_fields: &'static [PasswordField]) -> Engine { + Engine { + password_fields, + ..server(name, default_port) + } +} + +const fn own_sign_in(name: &'static str, default_port: u16) -> Engine { + Engine { + hides_password: true, + ..server(name, default_port) + } +} + +const fn file(name: &'static str, field: LocalFilePathField) -> Engine { + Engine { + file_path: Some(field), + ..server(name, 0) + } +} + +// The macOS app's built-in plugin metadata: every engine it knows, with the facts an import needs. +const ENGINES: &[Engine] = &[ + signs_in("MySQL", 3306, &[AWS_PICKER]), + signs_in("MariaDB", 3306, &[AWS_PICKER]), + signs_in("PostgreSQL", 5432, &[PGPASS, AWS_PICKER]), + signs_in("Redshift", 5439, &[PGPASS, AWS_PICKER]), + signs_in("CockroachDB", 26257, &[PGPASS]), + own_sign_in("PGlite", 5432), + file("SQLite", LocalFilePathField::Database), + server("TiDB", 4000), + server("Databend", 3307), + server("OceanBase", 2881), + signs_in("KingbaseES", 54321, &[PGPASS]), + server("StarRocks", 9030), + server("MongoDB", 27017), + signs_in("Redis", 6379, &[AWS_PICKER]), + signs_in( + "SQL Server", + 1433, + &[ + PasswordField { + id: "mssqlKerberosPassword", + shown: Shown::When { + field: "mssqlAuthMethod", + values: &["windows"], + field_default: "sql", + }, + }, + PasswordField { + id: "entraClientId", + shown: Shown::When { + field: "mssqlAuthMethod", + values: &["entra"], + field_default: "sql", + }, + }, + ], + ), + server("Teradata", 1025), + server("Oracle", 1521), + server("Dameng", 5236), + server("ClickHouse", 8123), + file("Beancount", LocalFilePathField::Database), + signs_in("Cassandra", 9042, &[AWS_PICKER]), + server("ScyllaDB", 9042), + server("etcd", 2379), + server("Cloudflare D1", 0), + Engine { + password_fields: &[LIBSQL_MODE], + ..file("libSQL", LocalFilePathField::AdditionalField("libsqlFilePath")) + }, + Engine { + password_fields: &[LIBSQL_MODE], + ..file("Turso", LocalFilePathField::AdditionalField("libsqlFilePath")) + }, + Engine { + default_port: 9494, + hides_password: true, + ..file("DuckDB", LocalFilePathField::AdditionalField("duckdbFilePath")) + }, + own_sign_in("DynamoDB", 0), + own_sign_in("BigQuery", 0), + own_sign_in("Spanner", 0), + own_sign_in("Snowflake", 443), + signs_in( + "Elasticsearch", + 9200, + &[PasswordField { + id: "esAuthMethod", + shown: Shown::Picker { default: "basic" }, + }], + ), + signs_in( + "SurrealDB", + 8000, + &[PasswordField { + id: "sdbToken", + shown: Shown::When { + field: "sdbAuthLevel", + values: &["token"], + field_default: "root", + }, + }], + ), + server("Kafka", 9092), + signs_in( + "Typesense", + 8108, + &[PasswordField { + id: "typesenseApiKey", + shown: Shown::Always, + }], + ), + server("Cloudflare R2 SQL", 0), + own_sign_in("Weaviate", 8080), + server("SAP HANA", 443), + own_sign_in("Firestore", 0), + own_sign_in("Firebase Realtime Database", 0), + own_sign_in("Bigtable", 0), + server("Neo4j", 7687), + signs_in("Trino", 8080, &[TRINO_TOKEN]), + signs_in("Presto", 8080, &[TRINO_TOKEN]), + server("Memcached", 11211), + own_sign_in("Qdrant", 6333), + own_sign_in("InfluxDB", 8086), +]; + +fn known(engine: &str) -> Option<&'static Engine> { + ENGINES.iter().find(|known| known.name == engine) +} + +// The exact name, else the one name that matches in any case, else the identifier as it came. +pub(crate) fn resolve(identifier: &str) -> String { + if let Some(engine) = known(identifier) { + return engine.name.to_owned(); + } + let folded = identifier.to_lowercase(); + let mut matches = ENGINES.iter().filter(|engine| engine.name.to_lowercase() == folded); + match (matches.next(), matches.next()) { + (Some(engine), None) => engine.name.to_owned(), + _ => identifier.to_owned(), + } +} + +pub(crate) fn default_port(engine: &str) -> u16 { + known(engine).map_or(0, |known| known.default_port) +} + +pub(crate) fn local_file_path_field(engine: &str) -> Option { + known(engine).and_then(|known| known.file_path) +} + +// Whether the engine's connection form shows no password of its own for these fields. +pub(crate) fn hides_built_in_password(engine: &str, fields: &BTreeMap) -> bool { + let Some(engine) = known(engine) else { + return false; + }; + engine.hides_password + || engine.password_fields.iter().any(|field| match field.shown { + Shown::Toggle => fields.get(field.id).is_some_and(|value| value == "true"), + Shown::Picker { default } => fields.get(field.id).is_some_and(|value| value != default), + Shown::Always => true, + Shown::When { + field: gate, + values, + field_default, + } => { + let current = fields.get(gate).map_or(field_default, String::as_str); + values.contains(¤t) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fields(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect() + } + + #[test] + fn every_engine_name_is_listed_once() { + let mut names: Vec<&str> = ENGINES.iter().map(|engine| engine.name).collect(); + assert_eq!(names.len(), 47); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), 47); + } + + #[test] + fn resolve_prefers_the_exact_name_then_a_single_match_in_any_case() { + assert_eq!(resolve("SQL Server"), "SQL Server"); + assert_eq!(resolve("sql server"), "SQL Server"); + assert_eq!(resolve("LIBSQL"), "libSQL"); + assert_eq!(resolve("ETCD"), "etcd"); + assert_eq!(resolve("firebase realtime database"), "Firebase Realtime Database"); + assert_eq!(resolve("Vertica"), "Vertica"); + assert_eq!(resolve("Greenplum"), "Greenplum"); + assert_eq!(resolve(""), ""); + } + + #[test] + fn default_ports_follow_the_macos_metadata() { + let expected = [ + ("MySQL", 3306), + ("PostgreSQL", 5432), + ("Redshift", 5439), + ("CockroachDB", 26257), + ("KingbaseES", 54321), + ("TiDB", 4000), + ("Databend", 3307), + ("OceanBase", 2881), + ("StarRocks", 9030), + ("MongoDB", 27017), + ("Redis", 6379), + ("SQL Server", 1433), + ("Teradata", 1025), + ("Oracle", 1521), + ("Dameng", 5236), + ("ClickHouse", 8123), + ("Cassandra", 9042), + ("etcd", 2379), + ("DuckDB", 9494), + ("Snowflake", 443), + ("Elasticsearch", 9200), + ("SurrealDB", 8000), + ("Kafka", 9092), + ("Typesense", 8108), + ("Weaviate", 8080), + ("SAP HANA", 443), + ("Neo4j", 7687), + ("Trino", 8080), + ("Memcached", 11211), + ("Qdrant", 6333), + ("InfluxDB", 8086), + ("SQLite", 0), + ("DynamoDB", 0), + ("Vertica", 0), + ]; + for (engine, port) in expected { + assert_eq!(default_port(engine), port, "{engine}"); + } + } + + #[test] + fn file_engines_name_the_field_that_holds_the_path() { + assert_eq!(local_file_path_field("SQLite"), Some(LocalFilePathField::Database)); + assert_eq!(local_file_path_field("Beancount"), Some(LocalFilePathField::Database)); + assert_eq!( + local_file_path_field("DuckDB"), + Some(LocalFilePathField::AdditionalField("duckdbFilePath")) + ); + assert_eq!( + local_file_path_field("libSQL"), + Some(LocalFilePathField::AdditionalField("libsqlFilePath")) + ); + assert_eq!( + local_file_path_field("Turso"), + Some(LocalFilePathField::AdditionalField("libsqlFilePath")) + ); + assert_eq!(local_file_path_field("MySQL"), None); + assert_eq!(local_file_path_field("sqlite"), None); + } + + #[test] + fn engines_that_sign_in_their_own_way_always_hide_the_password() { + for engine in [ + "PGlite", + "DuckDB", + "DynamoDB", + "BigQuery", + "Spanner", + "Snowflake", + "Weaviate", + "Firestore", + "Firebase Realtime Database", + "Bigtable", + "Qdrant", + "InfluxDB", + "Typesense", + ] { + assert!(hides_built_in_password(engine, &BTreeMap::new()), "{engine}"); + } + for engine in [ + "MySQL", + "PostgreSQL", + "SQL Server", + "Oracle", + "libSQL", + "Trino", + "Vertica", + ] { + assert!(!hides_built_in_password(engine, &BTreeMap::new()), "{engine}"); + } + } + + #[test] + fn sign_in_fields_hide_the_password_only_when_set() { + assert!(hides_built_in_password("MySQL", &fields(&[("awsAuth", "accessKey")]))); + assert!(!hides_built_in_password("MySQL", &fields(&[("awsAuth", "off")]))); + assert!(hides_built_in_password("PostgreSQL", &fields(&[("usePgpass", "true")]))); + assert!(!hides_built_in_password( + "PostgreSQL", + &fields(&[("usePgpass", "false")]) + )); + assert!(hides_built_in_password("KingbaseES", &fields(&[("usePgpass", "true")]))); + assert!(!hides_built_in_password("TiDB", &fields(&[("usePgpass", "true")]))); + assert!(hides_built_in_password( + "SQL Server", + &fields(&[("mssqlAuthMethod", "windows")]) + )); + assert!(hides_built_in_password( + "SQL Server", + &fields(&[("mssqlAuthMethod", "entra")]) + )); + assert!(!hides_built_in_password( + "SQL Server", + &fields(&[("mssqlAuthMethod", "sql")]) + )); + assert!(hides_built_in_password("libSQL", &fields(&[("libsqlMode", "local")]))); + assert!(!hides_built_in_password( + "libSQL", + &fields(&[("libsqlFilePath", "/data/local.db")]) + )); + assert!(hides_built_in_password( + "Elasticsearch", + &fields(&[("esAuthMethod", "apiKey")]) + )); + assert!(hides_built_in_password( + "SurrealDB", + &fields(&[("sdbAuthLevel", "token")]) + )); + assert!(!hides_built_in_password( + "SurrealDB", + &fields(&[("sdbAuthLevel", "record")]) + )); + assert!(hides_built_in_password( + "Presto", + &fields(&[("trinoAuthMethod", "jwt")]) + )); + assert!(!hides_built_in_password( + "Trino", + &fields(&[("trinoAuthMethod", "password")]) + )); + } +} diff --git a/core/crates/foreign-import/src/error.rs b/core/crates/foreign-import/src/error.rs new file mode 100644 index 0000000000..8ec921d133 --- /dev/null +++ b/core/crates/foreign-import/src/error.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; + +use thiserror::Error; + +use crate::ForeignApp; + +#[derive(Debug, Error)] +pub enum ForeignImportError { + #[error("could not find {} data files", .app.display_name())] + FileNotFound { app: ForeignApp }, + + #[error("could not read {}: {source}", .path.display())] + Unreadable { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to parse connections: {detail}")] + Parse { detail: String }, + + #[error("unsupported file format: {detail}")] + UnsupportedFormat { detail: String }, + + #[error("no connections found to import")] + NothingFound, + + #[error("the import was cancelled")] + Cancelled, +} + +impl ForeignImportError { + pub fn code(&self) -> &'static str { + match self { + Self::FileNotFound { .. } => "foreign_import.file_not_found", + Self::Unreadable { .. } => "foreign_import.unreadable", + Self::Parse { .. } => "foreign_import.parse", + Self::UnsupportedFormat { .. } => "foreign_import.unsupported_format", + Self::NothingFound => "foreign_import.nothing_found", + Self::Cancelled => "foreign_import.cancelled", + } + } + + pub(crate) fn parse(detail: impl Into) -> Self { + Self::Parse { detail: detail.into() } + } + + pub(crate) fn unsupported(detail: impl Into) -> Self { + Self::UnsupportedFormat { detail: detail.into() } + } +} diff --git a/core/crates/foreign-import/src/files.rs b/core/crates/foreign-import/src/files.rs new file mode 100644 index 0000000000..9e89f60adc --- /dev/null +++ b/core/crates/foreign-import/src/files.rs @@ -0,0 +1,70 @@ +use std::fs::{self, File}; +use std::io::{self, Read}; +use std::path::Path; + +// Far above any app's settings file, and small enough to hold in memory. +pub(crate) const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; + +// A path from another app's files can be crafted, such as a project's `.idea` folder linked to +// `/dev/zero` or a pipe, so a read takes only a regular file, and only up to the cap. +pub(crate) fn read_capped(path: impl AsRef) -> io::Result> { + let path = path.as_ref(); + let metadata = fs::metadata(path)?; + if !metadata.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file")); + } + if metadata.len() > MAX_FILE_BYTES { + return Err(too_large()); + } + let mut data = Vec::new(); + File::open(path)?.take(MAX_FILE_BYTES + 1).read_to_end(&mut data)?; + if u64::try_from(data.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES { + return Err(too_large()); + } + Ok(data) +} + +pub(crate) fn read_capped_string(path: impl AsRef) -> io::Result { + String::from_utf8(read_capped(path)?).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn too_large() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "larger than an app's settings file") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_regular_file_is_read() { + let dir = tempfile::tempdir().expect("a temp dir"); + let path = dir.path().join("settings.json"); + fs::write(&path, b"{}").expect("a file"); + assert_eq!(read_capped(&path).expect("the file"), b"{}"); + assert_eq!(read_capped_string(&path).expect("the file"), "{}"); + } + + #[test] + fn a_folder_or_a_file_past_the_cap_is_refused() { + let dir = tempfile::tempdir().expect("a temp dir"); + assert!(read_capped(dir.path()).is_err()); + let path = dir.path().join("huge.json"); + File::create(&path) + .and_then(|file| file.set_len(MAX_FILE_BYTES + 1)) + .expect("a sparse file"); + assert!(read_capped(&path).is_err()); + } + + #[cfg(unix)] + #[test] + fn a_link_to_a_device_is_refused_without_reading_it() { + let dir = tempfile::tempdir().expect("a temp dir"); + let link = dir.path().join("dataSources.xml"); + std::os::unix::fs::symlink("/dev/zero", &link).expect("a link"); + assert_eq!( + read_capped(&link).map_err(|error| error.kind()).err(), + Some(io::ErrorKind::InvalidInput) + ); + } +} diff --git a/core/crates/foreign-import/src/host.rs b/core/crates/foreign-import/src/host.rs new file mode 100644 index 0000000000..48472427fc --- /dev/null +++ b/core/crates/foreign-import/src/host.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use secrecy::SecretString; + +use crate::ForeignApp; + +#[derive(Debug, Clone)] +pub enum KeychainRead { + Found(SecretString), + NotFound, + // The user refused a prompt, or the keychain would not answer: the import stops asking. + Denied, +} + +// The keychain another app saved its passwords in. Only macOS lets one app read another's items, +// and only after the user allows it. +pub trait ForeignKeychain: Send + Sync { + fn read_password(&self, service: &str, account: Option<&str>) -> KeychainRead; +} + +pub trait ForeignHost: Send + Sync { + // `None` when the platform cannot tell, so an app counts as there when its files are. + fn installed(&self, _app: ForeignApp) -> Option { + None + } + + fn keychain(&self) -> Option> { + None + } +} + +// Linux and Windows: no installed-app lookup and no other app's keychain. +#[derive(Debug, Clone, Copy, Default)] +pub struct PlainHost; + +impl ForeignHost for PlainHost {} diff --git a/core/crates/foreign-import/src/lib.rs b/core/crates/foreign-import/src/lib.rs new file mode 100644 index 0000000000..c07acd0300 --- /dev/null +++ b/core/crates/foreign-import/src/lib.rs @@ -0,0 +1,24 @@ +// Saved connections and queries read from other database clients' own files, mapped to TablePro's +// terms, then reviewed against a connection library and added to it. + +mod app; +mod apps; +mod collect; +mod dirs; +mod engine; +mod error; +mod files; +mod host; +mod record; +pub mod review; +mod sql_file_tree; + +pub use app::{ForeignApp, ForeignImporter, ImportRequest, Inventory, PasswordSource, SavedQuerySupport}; +pub use collect::{CollectedConnection, CollectedImport, CollectedSavedQuery, PasswordOutcome}; +pub use dirs::{ForeignAppDirs, ForeignPlatform}; +pub use error::ForeignImportError; +pub use host::{ForeignHost, ForeignKeychain, KeychainRead, PlainHost}; +pub use record::{ + ForeignColor, ForeignConnection, ForeignConnectionRecord, ForeignCredentials, ForeignJumpHost, ForeignSavedQuery, + ForeignSsh, ForeignSshAuth, ForeignSsl, ForeignSslMode, SavedQueryContent, TLS_SETTINGS_UNREAD, +}; diff --git a/core/crates/foreign-import/src/record.rs b/core/crates/foreign-import/src/record.rs new file mode 100644 index 0000000000..9e14750349 --- /dev/null +++ b/core/crates/foreign-import/src/record.rs @@ -0,0 +1,123 @@ +use std::collections::BTreeMap; + +use secrecy::SecretString; + +// Set in `additional_fields` when the app saved TLS settings the importer cannot read, so the review +// says so and leaves the connection unchecked instead of importing it with TLS off. +pub const TLS_SETTINGS_UNREAD: &str = "tlsSettingsUnread"; + +// The fields follow the macOS app's exported connection, so every platform maps the same record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForeignConnection { + pub name: String, + pub host: String, + pub port: u16, + pub database: String, + pub username: String, + // TablePro's engine name (`MySQL`, `SQL Server`), or the other app's word when TablePro has none. + pub engine: String, + pub ssh: Option, + pub ssl: Option, + pub color: Option, + // Keyed as the macOS app keys them, such as `promptForPassword` or `awsRegion`. + pub additional_fields: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForeignSsh { + pub host: String, + pub port: Option, + pub username: String, + pub auth: ForeignSshAuth, + // As the app wrote it: absolute, `~/`-relative, or empty. + pub private_key_path: String, + pub jump_hosts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForeignJumpHost { + pub host: String, + pub port: Option, + pub username: String, + pub auth: ForeignSshAuth, + pub private_key_path: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForeignSshAuth { + Password, + PrivateKey, + Agent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForeignSsl { + pub mode: ForeignSslMode, + pub ca_certificate_path: Option, + pub client_certificate_path: Option, + pub client_key_path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForeignSslMode { + Disabled, + Preferred, + Required, + VerifyCa, + VerifyIdentity, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForeignColor { + Red, + Orange, + Yellow, + Green, + Blue, + Purple, + Pink, + Gray, +} + +#[derive(Debug, Clone, Default)] +pub struct ForeignCredentials { + pub password: Option, + pub ssh_password: Option, + pub key_passphrase: Option, +} + +impl ForeignCredentials { + pub fn is_empty(&self) -> bool { + self.password.is_none() && self.ssh_password.is_none() && self.key_passphrase.is_none() + } + + pub fn non_empty(self) -> Option { + if self.is_empty() { None } else { Some(self) } + } +} + +#[derive(Debug, Clone)] +pub struct ForeignConnectionRecord { + // The other app's id for the connection, which its saved queries name. + pub source_id: Option, + pub settings: ForeignConnection, + pub group_path: Vec, + pub credentials: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SavedQueryContent { + Text(String), + Oversized { byte_count: u64 }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForeignSavedQuery { + pub name: String, + pub content: SavedQueryContent, + pub keyword: Option, + pub folder_path: Vec, + pub source_connection_id: Option, + // Named by the other app, such as `console_3`, so the review leaves it unchecked. + pub is_auto_named: bool, +} diff --git a/core/crates/foreign-import/src/review/analyze.rs b/core/crates/foreign-import/src/review/analyze.rs new file mode 100644 index 0000000000..fceb56fe70 --- /dev/null +++ b/core/crates/foreign-import/src/review/analyze.rs @@ -0,0 +1,259 @@ +use std::collections::{HashMap, HashSet}; + +use tablepro_core::DriverRegistry; +use tablepro_storage::{ConnectionGroup, ConnectionGroupName, SavedConnection}; + +use super::mapping::{self, Mapped}; +use super::match_key::MatchKey; +use super::notes::{self, RowNote}; +use super::{ConnectionRow, ExistingConnection, ImportReview, QueryRow, Resolution, ReviewInputs}; +use crate::{CollectedConnection, CollectedImport, CollectedSavedQuery, SavedQueryContent}; + +// The cap the library puts on one saved query's text. +pub(super) const MAX_QUERY_BYTES: u64 = 1024 * 1024; + +impl ImportReview { + pub fn analyze(collected: CollectedImport, inputs: ReviewInputs<'_>) -> Self { + let queries = query_rows(&collected); + let mut query_counts: HashMap<&str, usize> = HashMap::new(); + for query in &queries { + if let Some(connection) = &query.connection { + *query_counts.entry(connection.as_str()).or_default() += 1; + } + } + + let mut existing_by_key: HashMap = HashMap::new(); + for existing in inputs.library.connections.iter() { + existing_by_key + .entry(library_key(existing, inputs.drivers)) + .or_insert(existing); + } + + let connections = collected + .connections + .iter() + .map(|connection| { + let saved_query_count = query_counts.get(connection.reference.as_str()).copied().unwrap_or(0); + row(connection, saved_query_count, &existing_by_key, &inputs) + }) + .collect(); + + Self { + app: collected.app, + passwords: collected.passwords, + connections, + queries, + library: inputs.library, + } + } +} + +fn row( + collected: &CollectedConnection, + saved_query_count: usize, + existing_by_key: &HashMap, + inputs: &ReviewInputs<'_>, +) -> ConnectionRow { + let settings = &collected.settings; + let driver = mapping::driver_id(&settings.engine).and_then(|id| inputs.drivers.get(id)); + let mapped = driver + .as_ref() + .map(|driver| mapping::map(settings, collected.credentials.as_ref(), driver.as_ref(), inputs.dirs)); + + let key = match (&mapped, &driver) { + (Some(mapped), Some(driver)) => connection_key(&mapped.connection, driver.capabilities().is_key_value()), + _ => MatchKey::new( + &settings.host, + settings.port, + &settings.database, + &settings.username, + false, + ), + }; + let existing = existing_by_key.get(&key).copied(); + let duplicate = existing.map(|existing| ExistingConnection { + id: existing.id, + name: existing.name.clone(), + }); + + let mut row_notes = Vec::new(); + if mapped.is_none() { + row_notes.push(RowNote::UnsupportedEngine { + engine: settings.engine.clone(), + }); + } + row_notes.extend(notes::missing_files(settings, inputs.dirs, inputs.file_exists)); + + let resolutions = match (&mapped, existing) { + (None, _) => Vec::new(), + (Some(_), None) => vec![Resolution::Add], + (Some(mapped), Some(existing)) => duplicate_resolutions(existing, &mapped.connection, saved_query_count > 0), + }; + let (connection, secrets) = match mapped { + Some(Mapped { + connection, + notes, + secrets, + }) => { + row_notes.extend(notes); + (Some(connection), secrets) + } + None => (None, Vec::new()), + }; + + ConnectionRow { + reference: collected.reference.clone(), + name: settings.name.clone(), + engine: settings.engine.clone(), + // A connection whose TLS could not be read waits for the user, since importing it turns TLS off. + selected_by_default: duplicate.is_none() + && connection.is_some() + && !row_notes.contains(&RowNote::TlsSettingsNotRead), + connection, + group_path: group_path(&collected.group_path), + duplicate, + resolutions, + notes: row_notes, + saved_query_count, + carries_credentials: !secrets.is_empty(), + secrets, + } +} + +// Replace keeps the saved id, and with it every secret saved under that id, so it is offered only to an +// entry that connects like the saved connection. Any other duplicate is added as a copy with a new id. +fn duplicate_resolutions( + existing: &SavedConnection, + mapped: &SavedConnection, + carries_queries: bool, +) -> Vec { + let mut resolutions = Vec::with_capacity(3); + if carries_queries { + resolutions.push(Resolution::KeepExisting(existing.id)); + } + resolutions.push(Resolution::AddCopy); + let in_place = SavedConnection { + id: existing.id, + ..mapped.clone() + }; + if existing.connects_like(&in_place) { + resolutions.push(Resolution::Replace(existing.id)); + } + resolutions +} + +fn library_key(saved: &SavedConnection, drivers: &DriverRegistry) -> MatchKey { + let key_value = drivers + .get(&saved.driver_id) + .is_some_and(|driver| driver.capabilities().is_key_value()); + connection_key(saved, key_value) +} + +fn connection_key(saved: &SavedConnection, key_value: bool) -> MatchKey { + MatchKey::new(&saved.host, saved.port, &saved.database, &saved.username, key_value) +} + +fn group_path(path: &[String]) -> Vec { + path.iter() + .filter_map(|name| ConnectionGroupName::new(name).ok()) + .take(ConnectionGroup::MAX_DEPTH) + .map(String::from) + .collect() +} + +fn query_rows(collected: &CollectedImport) -> Vec { + let mut connection_names: HashMap<&str, &str> = HashMap::new(); + for connection in &collected.connections { + connection_names + .entry(connection.reference.as_str()) + .or_insert(connection.settings.name.as_str()); + } + let connection_name = |query: &CollectedSavedQuery| { + query + .connection + .as_deref() + .and_then(|reference| connection_names.get(reference)) + .map(|name| (*name).to_owned()) + }; + + let mut rows = Vec::with_capacity(collected.saved_queries.len()); + let mut seen: HashSet<(Option<&str>, String, &str)> = HashSet::new(); + for query in &collected.saved_queries { + let SavedQueryContent::Text(text) = &query.content else { + continue; + }; + let key = ( + query.connection.as_deref(), + query.name.trim().to_lowercase(), + text.trim(), + ); + if !seen.insert(key) { + continue; + } + let byte_count = text.len() as u64; + rows.push(QueryRow { + reference: query.reference.clone(), + name: query_name(&query.name, text), + connection: query.connection.clone(), + connection_name: connection_name(query), + byte_count, + too_large: byte_count > MAX_QUERY_BYTES, + suggested: query.suggested, + text: Some(text.clone()), + }); + } + // As on macOS, files too large to read follow the rest. + for query in &collected.saved_queries { + let SavedQueryContent::Oversized { byte_count } = query.content else { + continue; + }; + rows.push(QueryRow { + reference: query.reference.clone(), + name: query_name(&query.name, ""), + connection: query.connection.clone(), + connection_name: connection_name(query), + byte_count, + too_large: true, + suggested: false, + text: None, + }); + } + rows +} + +fn query_name(name: &str, text: &str) -> String { + let trimmed = name.trim(); + if trimmed.is_empty() { + derived_name(text) + } else { + trimmed.to_owned() + } +} + +// The first line with text, or the text of the first comment that has some. +fn derived_name(text: &str) -> String { + for line in text.split(['\n', '\r']) { + let line = line.trim_matches([' ', '\t']); + let shown = match line.strip_prefix("--") { + Some(comment) => comment.trim_matches([' ', '\t']), + None => line, + }; + if !shown.is_empty() { + return shown.chars().take(50).collect(); + } + } + "Untitled".to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_blank_name_comes_from_the_first_comment_or_line_with_text() { + assert_eq!(derived_name("-- Active users\nselect 1"), "Active users"); + assert_eq!(derived_name("\r\n -- \n\tselect 2 \n"), "select 2"); + assert_eq!(derived_name(&"x".repeat(80)), "x".repeat(50)); + assert_eq!(derived_name(" \n--\n"), "Untitled"); + } +} diff --git a/core/crates/foreign-import/src/review/apply.rs b/core/crates/foreign-import/src/review/apply.rs new file mode 100644 index 0000000000..742e6ef9fb --- /dev/null +++ b/core/crates/foreign-import/src/review/apply.rs @@ -0,0 +1,159 @@ +use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet}; + +use tablepro_storage::saved_queries::NewSavedQuery; +use tablepro_storage::{ConnectionStore, ImportedConnection, SavedConnection, SavedQueries, StorageError}; +use uuid::Uuid; + +use super::plan::PlannedTarget; +use super::{ImportPlan, ImportReview, ImportedSecret}; + +#[derive(Debug, Clone)] +pub struct AppliedConnection { + pub reference: String, + // As saved, with its id and the name the library gave it. + pub connection: SavedConnection, + pub replaced: bool, + // For the keyring, under `connection.id`. + pub secrets: Vec, +} + +#[derive(Debug, Clone)] +pub struct ImportedQuery { + pub reference: String, + pub query: NewSavedQuery, +} + +#[derive(Debug, Clone, Default)] +pub struct AppliedImport { + pub connections: Vec, + pub queries: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct QueryImportOutcome { + pub added: usize, + // The connection already holds a query of that name, which a save would overwrite. + pub already_saved: usize, + pub failed: usize, +} + +// Refused with ImportOutdated when the library changed since the review, which would make its +// duplicates, and the connections Keep Existing names, stale. +pub fn apply_blocking( + review: &ImportReview, + plan: &ImportPlan, + store: &ConnectionStore, +) -> Result { + let entries = plan + .connections() + .iter() + .map(|planned| ImportedConnection { + connection: planned.connection.clone(), + group_path: planned.group_path.clone(), + replaces: planned.replaces, + }) + .collect(); + let saved = store.add_imported_blocking(review.library(), entries)?; + + let mut connections = Vec::with_capacity(saved.len()); + for (planned, connection) in plan.connections().iter().zip(saved) { + let secrets = review + .connection_row(&planned.reference) + .map(|row| row.secrets.clone()) + .unwrap_or_default(); + connections.push(AppliedConnection { + reference: planned.reference.clone(), + replaced: planned.replaces == Some(connection.id), + connection, + secrets, + }); + } + + let mut targets: HashMap<&str, (Uuid, &str)> = HashMap::new(); + let mut names: HashMap = review + .library() + .connections + .iter() + .map(|existing| (existing.id, existing.name.as_str())) + .collect(); + for applied in &connections { + targets.insert( + applied.reference.as_str(), + (applied.connection.id, applied.connection.name.as_str()), + ); + names.insert(applied.connection.id, applied.connection.name.as_str()); + } + let queries = plan + .queries() + .iter() + .filter_map(|planned| { + let (connection_id, connection_name) = match &planned.target { + PlannedTarget::Imported(reference) => targets.get(reference.as_str()).copied()?, + PlannedTarget::Existing(id) => (*id, names.get(id).copied()?), + }; + let text = review.query_row(&planned.reference)?.text.clone()?; + Some(ImportedQuery { + reference: planned.reference.clone(), + query: NewSavedQuery { + name: planned.name.clone(), + query: text, + connection_id, + connection_name: connection_name.to_owned(), + }, + }) + }) + .collect(); + Ok(AppliedImport { connections, queries }) +} + +// A name the connection already holds is left alone, since a save under it would overwrite that query. +pub async fn save_queries(applied: &AppliedImport, store: &SavedQueries) -> QueryImportOutcome { + let mut outcome = QueryImportOutcome::default(); + let mut held: HashMap>> = HashMap::new(); + for imported in &applied.queries { + let connection = imported.query.connection_id; + let names = match held.entry(connection) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => entry.insert(held_names(store, connection).await), + }; + let Some(names) = names else { + outcome.failed += 1; + continue; + }; + let name = imported.query.name.trim(); + if names.contains(name) { + outcome.already_saved += 1; + continue; + } + match store.save_if_new(imported.query.clone()).await { + Ok(Some(_)) => { + names.insert(name.to_owned()); + outcome.added += 1; + } + // Saved by another window since the names were read. + Ok(None) => { + names.insert(name.to_owned()); + outcome.already_saved += 1; + } + Err(error) => { + tracing::warn!(code = error.code(), "an imported saved query was not saved"); + outcome.failed += 1; + } + } + } + outcome +} + +async fn held_names(store: &SavedQueries, connection: Uuid) -> Option> { + match store.list(Some(connection)).await { + Ok(queries) => Some(queries.into_iter().map(|query| query.name).collect()), + Err(error) => { + tracing::warn!( + code = error.code(), + "the saved queries of an imported connection were not read" + ); + None + } + } +} diff --git a/core/crates/foreign-import/src/review/mapping.rs b/core/crates/foreign-import/src/review/mapping.rs new file mode 100644 index 0000000000..62ea972051 --- /dev/null +++ b/core/crates/foreign-import/src/review/mapping.rs @@ -0,0 +1,457 @@ +use std::path::{Path, PathBuf}; + +use secrecy::SecretString; +use tablepro_core::credentials::SecretKind; +use tablepro_core::unicode_blank::is_default_ignorable; +use tablepro_core::{ + AiPolicy, AuthMode, ClientIdentity, DatabaseDriver, DriverCapabilities, ExternalAccess, NetworkOption, + NetworkOptions, SafeModeLevel, TlsCapabilities, TlsConfig, TlsMode, +}; +use tablepro_storage::{ConnectionColor, SavedConnection, SavedSshAuth, SavedSshConfig}; +use uuid::Uuid; + +use super::notes::{RowNote, TlsFileReason}; +use crate::engine::{self, LocalFilePathField}; +use crate::{ + ForeignAppDirs, ForeignColor, ForeignConnection, ForeignCredentials, ForeignJumpHost, ForeignSsh, ForeignSshAuth, + ForeignSsl, ForeignSslMode, +}; + +const PROMPT_FOR_PASSWORD: &str = "promptForPassword"; +// The macOS MongoDB driver's switch for a DNS seed list and its auth database, which the core +// keeps in NetworkOptions. +const MONGO_USE_SRV: &str = "mongoUseSrv"; +const MONGO_AUTH_SOURCE: &str = "mongoAuthSource"; + +const DRIVER_IDS: &[(&str, &str)] = &[ + ("MySQL", "mysql"), + ("MariaDB", "mysql"), + ("PostgreSQL", "postgres"), + ("SQLite", "sqlite"), + ("DuckDB", "duckdb"), + ("SQL Server", "mssql"), + ("ClickHouse", "clickhouse"), + ("Cassandra", "cassandra"), + ("ScyllaDB", "cassandra"), + ("Redis", "redis"), + ("MongoDB", "mongodb"), +]; + +const TLS_STRENGTH: [TlsMode; 3] = [TlsMode::Disable, TlsMode::Require, TlsMode::VerifyFull]; + +#[derive(Debug, Clone)] +pub struct ImportedSecret { + pub kind: SecretKind, + pub secret: SecretString, +} + +pub(super) struct Mapped { + pub(super) connection: SavedConnection, + pub(super) notes: Vec, + pub(super) secrets: Vec, +} + +pub(super) fn driver_id(engine: &str) -> Option<&'static str> { + DRIVER_IDS + .iter() + .find(|(name, _)| *name == engine) + .or_else(|| DRIVER_IDS.iter().find(|(name, _)| name.eq_ignore_ascii_case(engine))) + .map(|(_, id)| *id) +} + +pub(super) fn map( + settings: &ForeignConnection, + credentials: Option<&ForeignCredentials>, + driver: &dyn DatabaseDriver, + dirs: &ForeignAppDirs, +) -> Mapped { + let caps = driver.capabilities(); + let mut notes = Vec::new(); + let file_based = caps.is_file_based(); + let (host, port, database, username, tls, auth_mode, ssh) = if file_based { + if settings.ssh.is_some() { + notes.push(RowNote::SshNotKept); + } + let tls = map_tls(settings.ssl.as_ref(), None, dirs, &mut notes); + let path = dirs.expand_home(file_path(settings)).display().to_string(); + (String::new(), 0, path, String::new(), tls, AuthMode::Password, None) + } else { + let port = match settings.port { + 0 => caps.default_port().unwrap_or(0), + port => port, + }; + let tls = map_tls(settings.ssl.as_ref(), caps.tls.as_ref(), dirs, &mut notes); + let ssh = settings + .ssh + .as_ref() + .map(|ssh| map_ssh(ssh, credentials, dirs, &mut notes)); + ( + settings.host.trim().to_owned(), + port, + settings.database.clone(), + settings.username.clone(), + tls, + caps.auth_modes.first().copied().unwrap_or_default(), + ssh, + ) + }; + let seed_list = settings + .additional_fields + .get(MONGO_USE_SRV) + .is_some_and(|value| value == "true") + && caps.network_options.contains(&NetworkOption::DnsSeedList); + let auth_database = settings + .additional_fields + .get(MONGO_AUTH_SOURCE) + .map(|name| name.trim().to_owned()) + .filter(|name| !name.is_empty() && caps.network_options.contains(&NetworkOption::AuthDatabase)); + if settings.additional_fields.contains_key(crate::TLS_SETTINGS_UNREAD) { + notes.push(RowNote::TlsSettingsNotRead); + } + let kept: Vec<&str> = [ + (seed_list, MONGO_USE_SRV), + (auth_database.is_some(), MONGO_AUTH_SOURCE), + (true, crate::TLS_SETTINGS_UNREAD), + ] + .into_iter() + .filter_map(|(kept, key)| kept.then_some(key)) + .collect(); + let dropped = dropped_fields(settings, &kept); + if !dropped.is_empty() { + notes.push(RowNote::FieldsNotKept { fields: dropped }); + } + let connection = SavedConnection { + id: Uuid::nil(), + name: connection_name(&settings.name, &host, &database, &settings.engine), + driver_id: driver.id().to_owned(), + host, + port, + database, + username, + tls, + safe_mode: SafeModeLevel::Off, + external_access: ExternalAccess::default(), + ai_policy: AiPolicy::default(), + query_timeout_secs: None, + auth_mode, + ssh, + last_opened_at: None, + color: settings.color.map(color), + group_id: None, + order: 0, + tags: Vec::new(), + icon: None, + options: NetworkOptions { + dns_seed_list: seed_list, + auth_database, + }, + }; + let secrets = secrets(&connection, caps, settings, credentials); + Mapped { + connection, + notes, + secrets, + } +} + +// Where the macOS app keeps the engine's file, then the database field another importer fills. +fn file_path(settings: &ForeignConnection) -> &str { + let field = match engine::local_file_path_field(&settings.engine) { + Some(LocalFilePathField::AdditionalField(key)) => settings.additional_fields.get(key), + Some(LocalFilePathField::Database) | None => None, + }; + field + .map(String::as_str) + .filter(|path| !path.trim().is_empty()) + .unwrap_or(&settings.database) +} + +fn dropped_fields(settings: &ForeignConnection, kept: &[&str]) -> Vec { + let path_field = match engine::local_file_path_field(&settings.engine) { + Some(LocalFilePathField::AdditionalField(key)) => Some(key), + _ => None, + }; + settings + .additional_fields + .keys() + .filter(|key| key.as_str() != PROMPT_FOR_PASSWORD && Some(key.as_str()) != path_field) + .filter(|key| !kept.contains(&key.as_str())) + .cloned() + .collect() +} + +// As the library reads a stored name, so an imported one cannot pass for a saved one. +fn connection_name(name: &str, host: &str, database: &str, engine: &str) -> String { + let shown: String = name + .chars() + .filter(|c| !is_default_ignorable(*c)) + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let shown = shown.trim(); + if !shown.is_empty() { + return shown.to_owned(); + } + let file_name = Path::new(database).file_name().and_then(|name| name.to_str()); + [Some(host), file_name] + .into_iter() + .flatten() + .find(|candidate| !candidate.is_empty()) + .unwrap_or(engine) + .to_owned() +} + +fn color(color: ForeignColor) -> ConnectionColor { + match color { + ForeignColor::Red => ConnectionColor::Red, + ForeignColor::Orange => ConnectionColor::Orange, + ForeignColor::Yellow => ConnectionColor::Yellow, + ForeignColor::Green => ConnectionColor::Green, + ForeignColor::Blue => ConnectionColor::Blue, + ForeignColor::Purple => ConnectionColor::Purple, + ForeignColor::Pink => ConnectionColor::Pink, + ForeignColor::Gray => ConnectionColor::Slate, + } +} + +fn natural_mode(mode: ForeignSslMode) -> TlsMode { + match mode { + ForeignSslMode::Disabled => TlsMode::Disable, + ForeignSslMode::Preferred | ForeignSslMode::Required => TlsMode::Require, + ForeignSslMode::VerifyCa | ForeignSslMode::VerifyIdentity => TlsMode::VerifyFull, + } +} + +fn strength(mode: TlsMode) -> usize { + TLS_STRENGTH.iter().position(|known| *known == mode).unwrap_or(0) +} + +// The weakest mode at least as strong as the one wanted, or the strongest below it when none is. +fn nearest_mode(wanted: TlsMode, offered: &[TlsMode]) -> Option { + let floor = strength(wanted); + let stronger = offered + .iter() + .copied() + .filter(|mode| strength(*mode) >= floor) + .min_by_key(|mode| strength(*mode)); + stronger.or_else(|| offered.iter().copied().max_by_key(|mode| strength(*mode))) +} + +fn written(path: Option<&str>) -> Option<&str> { + path.filter(|path| !path.trim().is_empty()) +} + +// The path as an absolute one, None for none written, Err for a relative one. +fn absolute_path(raw: Option<&str>, dirs: &ForeignAppDirs) -> Result, ()> { + let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok(None); + }; + let path = dirs.expand_home(raw); + if path.is_absolute() { Ok(Some(path)) } else { Err(()) } +} + +fn map_tls( + ssl: Option<&ForeignSsl>, + caps: Option<&TlsCapabilities>, + dirs: &ForeignAppDirs, + notes: &mut Vec, +) -> TlsConfig { + let from = ssl.map_or(ForeignSslMode::Disabled, |ssl| ssl.mode); + let wanted = natural_mode(from); + let Some((caps, mode)) = caps.and_then(|caps| nearest_mode(wanted, caps.modes).map(|mode| (caps, mode))) else { + if from != ForeignSslMode::Disabled { + notes.push(RowNote::TlsNotKept { from }); + } + return TlsConfig::disabled(); + }; + if mode != wanted || matches!(from, ForeignSslMode::Preferred | ForeignSslMode::VerifyCa) { + notes.push(RowNote::TlsModeChanged { from, to: mode }); + } + let mut config = TlsConfig { + mode, + ..TlsConfig::disabled() + }; + let Some(ssl) = ssl else { + return config; + }; + config.ca_file = ca_file(ssl, caps, mode, dirs, notes); + config.client_identity = client_identity(ssl, caps, mode, dirs, notes); + config +} + +fn ca_file( + ssl: &ForeignSsl, + caps: &TlsCapabilities, + mode: TlsMode, + dirs: &ForeignAppDirs, + notes: &mut Vec, +) -> Option { + let written = written(ssl.ca_certificate_path.as_deref())?; + let refused = |reason| { + Some(RowNote::CaFileNotKept { + path: written.to_owned(), + reason, + }) + }; + let (kept, note) = if !caps.ca_file { + (None, refused(TlsFileReason::EngineLacks)) + } else if mode != TlsMode::VerifyFull { + (None, refused(TlsFileReason::ModeLacks)) + } else { + match absolute_path(Some(written), dirs) { + Ok(path) => (path, None), + Err(()) => (None, refused(TlsFileReason::RelativePath)), + } + }; + notes.extend(note); + kept +} + +fn client_identity( + ssl: &ForeignSsl, + caps: &TlsCapabilities, + mode: TlsMode, + dirs: &ForeignAppDirs, + notes: &mut Vec, +) -> Option { + let certificate = written(ssl.client_certificate_path.as_deref()); + let key = written(ssl.client_key_path.as_deref()); + let mut refused = |reason| { + notes.push(RowNote::ClientIdentityNotKept { reason }); + None + }; + let (certificate, key) = match (certificate, key) { + (None, None) => return None, + (Some(certificate), Some(key)) => (certificate, key), + _ => return refused(TlsFileReason::Incomplete), + }; + if !caps.client_identity { + return refused(TlsFileReason::EngineLacks); + } + if mode == TlsMode::Disable { + return refused(TlsFileReason::ModeLacks); + } + match (absolute_path(Some(certificate), dirs), absolute_path(Some(key), dirs)) { + (Ok(Some(certificate)), Ok(Some(key))) => Some(ClientIdentity { certificate, key }), + _ => refused(TlsFileReason::RelativePath), + } +} + +fn map_ssh( + ssh: &ForeignSsh, + credentials: Option<&ForeignCredentials>, + dirs: &ForeignAppDirs, + notes: &mut Vec, +) -> SavedSshConfig { + let auth = match ssh.auth { + ForeignSshAuth::Password => SavedSshAuth::Password, + ForeignSshAuth::Agent => SavedSshAuth::Agent, + ForeignSshAuth::PrivateKey => { + let path = absolute_path(Some(&ssh.private_key_path), dirs).unwrap_or_else(|()| { + notes.push(RowNote::SshKeyNotKept { + path: ssh.private_key_path.clone(), + }); + None + }); + SavedSshAuth::PrivateKey { + path, + has_passphrase: credentials.is_some_and(|credentials| credentials.key_passphrase.is_some()), + } + } + }; + let jump_hosts = ssh + .jump_hosts + .iter() + .filter_map(|jump| jump_host(jump, notes)) + .collect(); + SavedSshConfig { + host: ssh.host.trim().to_owned(), + port: ssh.port.filter(|port| *port != 0), + username: Some(ssh.username.trim().to_owned()).filter(|user| !user.is_empty()), + jump_hosts, + auth, + } +} + +// One `user@host:port` hop of the list ssh -J takes, or None when the hop cannot be written as one. +fn jump_host(jump: &ForeignJumpHost, notes: &mut Vec) -> Option { + let host = jump.host.trim(); + let host = host + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + .unwrap_or(host); + let user = jump.username.trim(); + let breaks_list = |part: &str, extra: &[char]| { + part.chars() + .any(|c| c.is_whitespace() || c == ',' || extra.contains(&c)) + }; + if host.is_empty() || breaks_list(host, &['@', '[', ']']) || breaks_list(user, &[]) { + notes.push(RowNote::JumpHostNotKept { + host: jump.host.clone(), + }); + return None; + } + match jump.auth { + ForeignSshAuth::Password => notes.push(RowNote::JumpHostPasswordNotKept { host: host.to_owned() }), + ForeignSshAuth::PrivateKey if !jump.private_key_path.trim().is_empty() => { + notes.push(RowNote::JumpHostKeyNotKept { + host: host.to_owned(), + path: jump.private_key_path.clone(), + }); + } + ForeignSshAuth::PrivateKey | ForeignSshAuth::Agent => {} + } + let mut hop = String::new(); + if !user.is_empty() { + hop.push_str(user); + hop.push('@'); + } + if host.contains(':') { + hop.push_str(&format!("[{host}]")); + } else { + hop.push_str(host); + } + if let Some(port) = jump.port.filter(|port| *port != 0) { + hop.push_str(&format!(":{port}")); + } + Some(hop) +} + +fn secrets( + connection: &SavedConnection, + caps: &DriverCapabilities, + settings: &ForeignConnection, + credentials: Option<&ForeignCredentials>, +) -> Vec { + let Some(credentials) = credentials else { + return Vec::new(); + }; + let prompts = settings + .additional_fields + .get(PROMPT_FOR_PASSWORD) + .is_some_and(|value| value == "true"); + let signs_in_with_password = + !caps.is_file_based() && caps.supports(AuthMode::Password) && connection.auth_mode == AuthMode::Password; + let ssh_auth = connection.ssh.as_ref().map(|ssh| &ssh.auth); + let wanted = [ + ( + SecretKind::DatabasePassword, + &credentials.password, + signs_in_with_password && !prompts, + ), + ( + SecretKind::SshPassword, + &credentials.ssh_password, + matches!(ssh_auth, Some(SavedSshAuth::Password)), + ), + ( + SecretKind::SshPassphrase, + &credentials.key_passphrase, + matches!(ssh_auth, Some(SavedSshAuth::PrivateKey { .. })), + ), + ]; + wanted + .into_iter() + .filter(|(_, _, kept)| *kept) + .filter_map(|(kind, secret, _)| secret.clone().map(|secret| ImportedSecret { kind, secret })) + .collect() +} diff --git a/core/crates/foreign-import/src/review/match_key.rs b/core/crates/foreign-import/src/review/match_key.rs new file mode 100644 index 0000000000..818ccd2477 --- /dev/null +++ b/core/crates/foreign-import/src/review/match_key.rs @@ -0,0 +1,61 @@ +// Two entries name one connection when these match. The core keeps a Redis database index in +// `database` and has no socket path, so the macOS key's extra fields have no home here. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(super) struct MatchKey { + host: String, + port: u16, + database: String, + username: String, +} + +impl MatchKey { + pub(super) fn new(host: &str, port: u16, database: &str, username: &str, key_value: bool) -> Self { + let database = normalized(database); + // A key-value engine opens database 0 when none is named. + let database = if key_value && database.is_empty() { + "0".to_owned() + } else { + database + }; + Self { + host: normalized(host), + port, + database, + username: normalized(username), + } + } +} + +fn normalized(value: &str) -> String { + value.trim().to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_database_and_username_match_ignoring_case_and_surrounding_spaces() { + let local = MatchKey::new("db.example.com", 5432, "app", "admin", false); + let imported = MatchKey::new(" DB.example.com ", 5432, " App ", " ADMIN ", false); + assert_eq!(local, imported); + } + + #[test] + fn a_different_port_or_username_is_a_different_connection() { + let base = MatchKey::new("h", 5432, "app", "admin", false); + assert_ne!(base, MatchKey::new("h", 5433, "app", "admin", false)); + assert_ne!(base, MatchKey::new("h", 5432, "app", "readonly", false)); + } + + #[test] + fn a_key_value_database_index_distinguishes_and_none_named_is_zero() { + let zero = MatchKey::new("cache", 6379, "", "", true); + assert_eq!(zero, MatchKey::new("cache", 6379, " 0 ", "", true)); + assert_ne!(zero, MatchKey::new("cache", 6379, "1", "", true)); + assert_ne!( + MatchKey::new("h", 1, "", "", false), + MatchKey::new("h", 1, "0", "", false) + ); + } +} diff --git a/core/crates/foreign-import/src/review/mod.rs b/core/crates/foreign-import/src/review/mod.rs new file mode 100644 index 0000000000..5973af2630 --- /dev/null +++ b/core/crates/foreign-import/src/review/mod.rs @@ -0,0 +1,120 @@ +// What another app's connections and queries become in the core library: a row per collected +// connection with its duplicate and notes, a selection over the rows, a plan, and its write. + +mod analyze; +mod apply; +mod mapping; +mod match_key; +mod notes; +mod plan; +mod selection; + +use std::path::Path; + +use tablepro_core::DriverRegistry; +use tablepro_storage::{ConnectionList, SavedConnection}; +use uuid::Uuid; + +use crate::{ForeignApp, ForeignAppDirs, PasswordOutcome}; + +pub use apply::{AppliedConnection, AppliedImport, ImportedQuery, QueryImportOutcome, apply_blocking, save_queries}; +pub use mapping::ImportedSecret; +pub use notes::{RowNote, TlsFileReason}; +pub use plan::{ImportPlan, PlannedConnection, PlannedQuery, QueryAvailability, QueryStatus, QueryTarget, plan}; +pub use selection::ImportSelection; + +pub struct ReviewInputs<'a> { + pub drivers: &'a DriverRegistry, + // The list the rows are checked against, and the one the write expects to find unchanged. + pub library: ConnectionList, + pub dirs: &'a ForeignAppDirs, + pub file_exists: &'a dyn Fn(&Path) -> bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Resolution { + Add, + AddCopy, + Replace(Uuid), + // Writes nothing, so the row's saved queries land on the saved connection. + KeepExisting(Uuid), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExistingConnection { + pub id: Uuid, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct ConnectionRow { + pub reference: String, + pub name: String, + // As the other app named it. + pub engine: String, + // None when no driver here opens the engine; such a row cannot be selected. + pub connection: Option, + pub group_path: Vec, + pub duplicate: Option, + // The first is the default once the row is selected. + pub resolutions: Vec, + pub selected_by_default: bool, + pub notes: Vec, + pub saved_query_count: usize, + // Whether the write hands back secrets for the keyring. + pub carries_credentials: bool, + secrets: Vec, +} + +#[derive(Debug, Clone)] +pub struct QueryRow { + pub reference: String, + pub name: String, + // The reference of the connection row it runs on. + pub connection: Option, + pub connection_name: Option, + pub byte_count: u64, + pub too_large: bool, + pub suggested: bool, + // None when the other app's file was too large to read. + text: Option, +} + +#[derive(Debug, Clone)] +pub struct ImportReview { + app: ForeignApp, + passwords: PasswordOutcome, + connections: Vec, + queries: Vec, + library: ConnectionList, +} + +impl ImportReview { + pub fn app(&self) -> ForeignApp { + self.app + } + + pub fn passwords(&self) -> PasswordOutcome { + self.passwords + } + + pub fn connections(&self) -> &[ConnectionRow] { + &self.connections + } + + pub fn saved_queries(&self) -> &[QueryRow] { + &self.queries + } + + pub fn library(&self) -> &ConnectionList { + &self.library + } + + pub fn connection_row(&self, reference: &str) -> Option<&ConnectionRow> { + self.connections.iter().find(|row| row.reference == reference) + } + + pub fn query_row(&self, reference: &str) -> Option<&QueryRow> { + self.queries.iter().find(|row| row.reference == reference) + } +} diff --git a/core/crates/foreign-import/src/review/notes.rs b/core/crates/foreign-import/src/review/notes.rs new file mode 100644 index 0000000000..dd3dae7e0c --- /dev/null +++ b/core/crates/foreign-import/src/review/notes.rs @@ -0,0 +1,81 @@ +use std::path::Path; + +use tablepro_core::TlsMode; + +use crate::{ForeignAppDirs, ForeignConnection, ForeignSslMode}; + +// What the review tells the user about a row. Paths are as the other app wrote them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RowNote { + UnsupportedEngine { engine: String }, + SshKeyMissing { path: String }, + JumpHostKeyMissing { path: String }, + CaCertificateMissing { path: String }, + ClientCertificateMissing { path: String }, + ClientKeyMissing { path: String }, + TlsModeChanged { from: ForeignSslMode, to: TlsMode }, + // The app saved TLS settings the importer cannot read, so the row starts unchecked. + TlsSettingsNotRead, + // The engine has no TLS, so the connection opens in the clear. + TlsNotKept { from: ForeignSslMode }, + CaFileNotKept { path: String, reason: TlsFileReason }, + ClientIdentityNotKept { reason: TlsFileReason }, + // A file engine opens a local file, never through a tunnel. + SshNotKept, + SshKeyNotKept { path: String }, + // The host or user cannot be written as one hop of the jump list. + JumpHostNotKept { host: String }, + // A hop signs in through the SSH agent or the SSH config. + JumpHostPasswordNotKept { host: String }, + JumpHostKeyNotKept { host: String, path: String }, + FieldsNotKept { fields: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TlsFileReason { + EngineLacks, + // A CA file needs Verify Full, and a client identity needs TLS on. + ModeLacks, + RelativePath, + // A certificate without its key, or a key without its certificate. + Incomplete, +} + +pub(super) fn missing_files( + settings: &ForeignConnection, + dirs: &ForeignAppDirs, + file_exists: &dyn Fn(&Path) -> bool, +) -> Vec { + let missing = |path: &str| { + let expanded = dirs.expand_home(path); + !expanded.as_os_str().is_empty() && !file_exists(&expanded) + }; + let mut notes = Vec::new(); + if let Some(ssh) = &settings.ssh { + if missing(&ssh.private_key_path) { + notes.push(RowNote::SshKeyMissing { + path: ssh.private_key_path.clone(), + }); + } + for jump in &ssh.jump_hosts { + if missing(&jump.private_key_path) { + notes.push(RowNote::JumpHostKeyMissing { + path: jump.private_key_path.clone(), + }); + } + } + } + if let Some(ssl) = &settings.ssl { + let missing_path = |path: &Option| path.clone().filter(|path| missing(path)); + if let Some(path) = missing_path(&ssl.ca_certificate_path) { + notes.push(RowNote::CaCertificateMissing { path }); + } + if let Some(path) = missing_path(&ssl.client_certificate_path) { + notes.push(RowNote::ClientCertificateMissing { path }); + } + if let Some(path) = missing_path(&ssl.client_key_path) { + notes.push(RowNote::ClientKeyMissing { path }); + } + } + notes +} diff --git a/core/crates/foreign-import/src/review/plan.rs b/core/crates/foreign-import/src/review/plan.rs new file mode 100644 index 0000000000..366a8ab143 --- /dev/null +++ b/core/crates/foreign-import/src/review/plan.rs @@ -0,0 +1,202 @@ +use std::collections::{HashMap, HashSet}; + +use tablepro_storage::{ConnectionGroupName, SavedConnection}; +use uuid::Uuid; + +use super::{ImportReview, ImportSelection, Resolution}; + +// A name longer than the library keeps. +const MAX_NAME_CHARS: usize = 200; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlannedConnection { + pub reference: String, + pub connection: SavedConnection, + pub group_path: Vec, + pub replaces: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum QueryTarget<'a> { + // The connection this import writes for that row. + Imported(&'a str), + Existing(Uuid), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlannedQuery { + pub reference: String, + pub name: String, + pub(super) target: PlannedTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(super) enum PlannedTarget { + Imported(String), + Existing(Uuid), +} + +impl PlannedQuery { + pub fn target(&self) -> QueryTarget<'_> { + match &self.target { + PlannedTarget::Imported(reference) => QueryTarget::Imported(reference), + PlannedTarget::Existing(id) => QueryTarget::Existing(*id), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueryAvailability { + Available, + // The library keeps one query per name on a connection, and an earlier row of this import takes it. + AddedByAnotherRow, + TooLarge, + NameTooLong, + // Its connection is not imported, or its Replace went to another row. + ConnectionSkipped, + // The library keeps each query under one connection, and the other app filed this one under none. + NoConnection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QueryStatus { + pub availability: QueryAvailability, + pub included: bool, +} + +impl QueryStatus { + fn unavailable(availability: QueryAvailability) -> Self { + Self { + availability, + included: false, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ImportPlan { + connections: Vec, + kept: Vec<(String, Uuid)>, + queries: Vec, + query_statuses: HashMap, +} + +impl ImportPlan { + pub fn is_empty(&self) -> bool { + self.connections.is_empty() && self.queries.is_empty() + } + + pub fn connections(&self) -> &[PlannedConnection] { + &self.connections + } + + // Rows that write nothing and lend their saved connection to their queries. + pub fn kept_connections(&self) -> &[(String, Uuid)] { + &self.kept + } + + pub fn queries(&self) -> &[PlannedQuery] { + &self.queries + } + + pub fn query_statuses(&self) -> &HashMap { + &self.query_statuses + } + + pub fn query_status(&self, reference: &str) -> Option { + self.query_statuses.get(reference).copied() + } +} + +pub fn plan(review: &ImportReview, selection: &ImportSelection) -> ImportPlan { + let mut plan = ImportPlan::default(); + let targets = plan_connections(review, selection, &mut plan); + plan_queries(review, selection, &targets, &mut plan); + plan +} + +fn plan_connections( + review: &ImportReview, + selection: &ImportSelection, + plan: &mut ImportPlan, +) -> HashMap { + let mut targets = HashMap::new(); + let mut replaced = HashSet::new(); + for row in review.connections() { + let (Some(resolution), Some(connection)) = (selection.resolution(&row.reference), &row.connection) else { + continue; + }; + let mut connection = connection.clone(); + let replaces = match resolution { + Resolution::KeepExisting(id) => { + plan.kept.push((row.reference.clone(), id)); + targets.insert(row.reference.clone(), PlannedTarget::Existing(id)); + continue; + } + Resolution::Add | Resolution::AddCopy => { + targets.insert(row.reference.clone(), PlannedTarget::Imported(row.reference.clone())); + None + } + Resolution::Replace(id) => { + if !replaced.insert(id) { + continue; + } + // The write keeps the id, so a row keeping the same connection shares its query names. + connection.id = id; + targets.insert(row.reference.clone(), PlannedTarget::Existing(id)); + Some(id) + } + }; + plan.connections.push(PlannedConnection { + reference: row.reference.clone(), + connection, + group_path: row + .group_path + .iter() + .filter_map(|name| ConnectionGroupName::new(name).ok()) + .collect(), + replaces, + }); + } + targets +} + +fn plan_queries( + review: &ImportReview, + selection: &ImportSelection, + targets: &HashMap, + plan: &mut ImportPlan, +) { + let mut taken: HashSet<(PlannedTarget, String)> = HashSet::new(); + for row in review.saved_queries() { + let status = match row.connection.as_ref().map(|connection| targets.get(connection)) { + None => QueryStatus::unavailable(QueryAvailability::NoConnection), + Some(None) => QueryStatus::unavailable(QueryAvailability::ConnectionSkipped), + Some(Some(_)) if row.too_large || row.text.is_none() => { + QueryStatus::unavailable(QueryAvailability::TooLarge) + } + Some(Some(_)) if row.name.chars().count() > MAX_NAME_CHARS => { + QueryStatus::unavailable(QueryAvailability::NameTooLong) + } + Some(Some(target)) if taken.contains(&(target.clone(), row.name.clone())) => { + QueryStatus::unavailable(QueryAvailability::AddedByAnotherRow) + } + Some(Some(target)) => { + let included = selection.wants_query(row); + if included { + taken.insert((target.clone(), row.name.clone())); + plan.queries.push(PlannedQuery { + reference: row.reference.clone(), + name: row.name.clone(), + target: target.clone(), + }); + } + QueryStatus { + availability: QueryAvailability::Available, + included, + } + } + }; + plan.query_statuses.insert(row.reference.clone(), status); + } +} diff --git a/core/crates/foreign-import/src/review/selection.rs b/core/crates/foreign-import/src/review/selection.rs new file mode 100644 index 0000000000..d961cc21e1 --- /dev/null +++ b/core/crates/foreign-import/src/review/selection.rs @@ -0,0 +1,103 @@ +use std::collections::{HashMap, HashSet}; + +use super::{ConnectionRow, ImportReview, QueryRow, Resolution}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ImportSelection { + selected: HashSet, + // Kept across uncheck, so checking a row again restores what the user picked. + chosen: HashMap, + query_overrides: HashMap, +} + +impl ImportSelection { + pub fn defaults(review: &ImportReview) -> Self { + let mut selection = Self::default(); + for row in review.connections() { + if let Some(first) = row.resolutions.first() { + selection.chosen.insert(row.reference.clone(), *first); + } + if row.selected_by_default { + selection.selected.insert(row.reference.clone()); + } + } + selection + } + + pub fn resolution(&self, reference: &str) -> Option { + if !self.selected.contains(reference) { + return None; + } + self.chosen.get(reference).copied() + } + + pub fn is_selected(&self, reference: &str) -> bool { + self.selected.contains(reference) + } + + pub fn set_selected(&mut self, selected: bool, reference: &str, review: &ImportReview) { + if !selected { + self.selected.remove(reference); + return; + } + let Some(row) = review.connection_row(reference) else { + return; + }; + let Some(fallback) = row.resolutions.first().copied() else { + return; + }; + let keeps_stored = self + .chosen + .get(reference) + .is_some_and(|stored| row.resolutions.contains(stored) && !self.is_replace_held(*stored, reference)); + if !keeps_stored { + self.chosen.insert(reference.to_owned(), fallback); + } + self.selected.insert(reference.to_owned()); + } + + pub fn resolve(&mut self, reference: &str, resolution: Resolution, review: &ImportReview) -> bool { + let offered = review + .connection_row(reference) + .is_some_and(|row| row.resolutions.contains(&resolution)); + if !offered || self.is_replace_held(resolution, reference) { + return false; + } + self.chosen.insert(reference.to_owned(), resolution); + true + } + + pub fn offered_resolutions(&self, row: &ConnectionRow) -> Vec { + row.resolutions + .iter() + .copied() + .filter(|resolution| !self.is_replace_held(*resolution, &row.reference)) + .collect() + } + + pub fn set_included(&mut self, included: bool, reference: &str) { + self.query_overrides.insert(reference.to_owned(), included); + } + + // The user's choice for a row, which can differ from whether it imports: a row another selected + // row already adds keeps its choice for when that row is unchecked. + pub fn wants_query(&self, row: &QueryRow) -> bool { + self.query_overrides + .get(&row.reference) + .copied() + .unwrap_or(row.suggested) + } + + pub fn query_override(&self, reference: &str) -> Option { + self.query_overrides.get(reference).copied() + } + + fn is_replace_held(&self, resolution: Resolution, reference: &str) -> bool { + let Resolution::Replace(target) = resolution else { + return false; + }; + self.selected + .iter() + .any(|other| other != reference && self.chosen.get(other) == Some(&Resolution::Replace(target))) + } +} diff --git a/core/crates/foreign-import/src/sql_file_tree.rs b/core/crates/foreign-import/src/sql_file_tree.rs new file mode 100644 index 0000000000..43503427a8 --- /dev/null +++ b/core/crates/foreign-import/src/sql_file_tree.rs @@ -0,0 +1,218 @@ +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use tokio_util::sync::CancellationToken; + +use crate::SavedQueryContent; + +pub(crate) const MAXIMUM_ENTRY_COUNT: usize = 10_000; +// Leaves room for the app and shared-folder names above a file's own folders. +pub(crate) const MAXIMUM_DEPTH: usize = 30; +// A tree of folders with few matching files would otherwise be walked in full. +pub(crate) const MAXIMUM_DIRECTORY_COUNT: usize = 2_000; +const INSPECTED_BYTE_COUNT: u64 = 4_096; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SqlFileEntry { + pub path: PathBuf, + pub folder_path: Vec, + pub file_name: String, + pub byte_count: u64, +} + +impl SqlFileEntry { + // The file name without its last extension, as the other apps show it. + pub fn stem(&self) -> String { + file_stem(&self.file_name) + } +} + +pub(crate) fn file_stem(file_name: &str) -> String { + match file_name.rfind('.') { + Some(0) | None => file_name.to_owned(), + Some(dot) => file_name[..dot].to_owned(), + } +} + +pub(crate) fn entries(root: &Path, accepts: &dyn Fn(&str) -> bool, cancel: &CancellationToken) -> Vec { + let mut found = Vec::new(); + let mut budget = MAXIMUM_DIRECTORY_COUNT; + walk(root, &mut Vec::new(), accepts, cancel, &mut found, &mut budget); + if found.len() >= MAXIMUM_ENTRY_COUNT || budget == 0 { + tracing::warn!(entries = found.len(), "stopped listing saved query files"); + } + found +} + +fn walk( + directory: &Path, + folder_path: &mut Vec, + accepts: &dyn Fn(&str) -> bool, + cancel: &CancellationToken, + found: &mut Vec, + budget: &mut usize, +) { + if folder_path.len() > MAXIMUM_DEPTH || *budget == 0 || cancel.is_cancelled() { + return; + } + *budget -= 1; + let Ok(children) = fs::read_dir(directory) else { + return; + }; + let mut children: Vec<(String, PathBuf)> = children + .filter_map(Result::ok) + .filter_map(|child| child.file_name().into_string().ok().map(|name| (name, child.path()))) + .collect(); + children.sort_by(|left, right| left.0.cmp(&right.0)); + + for (name, path) in children { + if found.len() >= MAXIMUM_ENTRY_COUNT { + return; + } + if name.starts_with('.') { + continue; + } + // Never followed: a link could lead out of the app's folder or back into it. + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if metadata.file_type().is_symlink() { + continue; + } + if metadata.is_dir() { + folder_path.push(name); + walk(&path, folder_path, accepts, cancel, found, budget); + folder_path.pop(); + } else if metadata.is_file() && accepts(&name) { + found.push(SqlFileEntry { + path, + folder_path: folder_path.clone(), + file_name: name, + byte_count: metadata.len(), + }); + } + } +} + +pub(crate) fn content(entry: &SqlFileEntry, limit: u64) -> Option { + if entry.byte_count > limit { + return Some(SavedQueryContent::Oversized { + byte_count: entry.byte_count, + }); + } + let file = File::open(&entry.path).ok()?; + let mut data = Vec::new(); + file.take(limit.saturating_add(1)).read_to_end(&mut data).ok()?; + if data.is_empty() { + return None; + } + let read = u64::try_from(data.len()).unwrap_or(u64::MAX); + // The file can grow between listing and reading. + if read > limit { + return Some(SavedQueryContent::Oversized { + byte_count: entry.byte_count.max(read), + }); + } + text(&data).map(SavedQueryContent::Text) +} + +// Counting must stay cheap, so only a small file is opened to rule out one that is blank. +pub(crate) fn is_countable(entry: &SqlFileEntry) -> bool { + if entry.byte_count == 0 { + return false; + } + if entry.byte_count > INSPECTED_BYTE_COUNT { + return true; + } + fs::read(&entry.path).ok().is_some_and(|data| text(&data).is_some()) +} + +pub(crate) fn text(data: &[u8]) -> Option { + let text = std::str::from_utf8(data).ok()?; + let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); + if text.trim().is_empty() { + None + } else { + Some(text.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(path: &Path, contents: &[u8]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("a folder"); + } + fs::write(path, contents).expect("a file"); + } + + fn all(_: &str) -> bool { + true + } + + #[test] + fn files_are_listed_in_byte_order_of_names_with_their_folders_skipping_hidden_ones() { + let root = tempfile::tempdir().expect("a temp dir"); + write(&root.path().join("b.sql"), b"SELECT 2"); + write(&root.path().join("a.sql"), b"SELECT 1"); + write(&root.path().join("Reports/Daily/sales.sql"), b"SELECT 3"); + write(&root.path().join(".hidden.sql"), b"SELECT 4"); + write(&root.path().join(".git/x.sql"), b"SELECT 5"); + let listed = entries(root.path(), &all, &CancellationToken::new()); + let names: Vec<(&[String], &str)> = listed + .iter() + .map(|entry| (entry.folder_path.as_slice(), entry.file_name.as_str())) + .collect(); + assert_eq!( + names, + [ + (&["Reports".to_owned(), "Daily".to_owned()] as &[String], "sales.sql"), + (&[] as &[String], "a.sql"), + (&[] as &[String], "b.sql"), + ] + ); + } + + #[cfg(unix)] + #[test] + fn a_symbolic_link_is_never_followed() { + let root = tempfile::tempdir().expect("a temp dir"); + let outside = tempfile::tempdir().expect("a temp dir"); + write(&outside.path().join("secret.sql"), b"SELECT 1"); + std::os::unix::fs::symlink(outside.path(), root.path().join("linked")).expect("a link"); + std::os::unix::fs::symlink(outside.path().join("secret.sql"), root.path().join("file.sql")).expect("a link"); + assert!(entries(root.path(), &all, &CancellationToken::new()).is_empty()); + } + + #[test] + fn content_is_text_oversized_or_nothing() { + let root = tempfile::tempdir().expect("a temp dir"); + write(&root.path().join("bom.sql"), "\u{FEFF}SELECT 1".as_bytes()); + write(&root.path().join("blank.sql"), b" \n\t"); + write(&root.path().join("big.sql"), &[b'x'; 30]); + write(&root.path().join("binary.sql"), &[0xff, 0xfe, 0x00]); + let listed = entries(root.path(), &all, &CancellationToken::new()); + let by_name = |name: &str| listed.iter().find(|entry| entry.file_name == name).expect("an entry"); + assert_eq!( + content(by_name("bom.sql"), 20), + Some(SavedQueryContent::Text("SELECT 1".to_owned())) + ); + assert_eq!(content(by_name("blank.sql"), 20), None); + assert_eq!(content(by_name("binary.sql"), 20), None); + assert_eq!( + content(by_name("big.sql"), 20), + Some(SavedQueryContent::Oversized { byte_count: 30 }) + ); + assert!(!is_countable(by_name("blank.sql"))); + assert!(is_countable(by_name("bom.sql"))); + } + + #[test] + fn the_stem_drops_only_the_last_extension() { + assert_eq!(file_stem("report.daily.sql"), "report.daily"); + assert_eq!(file_stem("console"), "console"); + } +} diff --git a/core/crates/foreign-import/tests/beekeeper.rs b/core/crates/foreign-import/tests/beekeeper.rs new file mode 100644 index 0000000000..3520d41595 --- /dev/null +++ b/core/crates/foreign-import/tests/beekeeper.rs @@ -0,0 +1,583 @@ +#![expect( + clippy::expect_used, + reason = "the setup helpers fail a test the way the test functions themselves do" +)] + +use std::ffi::CString; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::ptr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use secrecy::{ExposeSecret, SecretString}; +use tablepro_foreign_import::{ + CollectedImport, ForeignApp, ForeignAppDirs, ForeignColor, ForeignHost, ForeignImportError, ForeignImporter, + ForeignKeychain, ForeignPlatform, ForeignSshAuth, ForeignSslMode, ImportRequest, Inventory, KeychainRead, + PasswordOutcome, PasswordSource, SavedQueryContent, SavedQuerySupport, +}; +use tempfile::TempDir; +use tokio_util::sync::CancellationToken; + +const BYTE_LIMIT: u64 = 900_000; + +const CONNECTIONS_ONLY: ImportRequest = ImportRequest { + include_passwords: false, + include_saved_queries: false, + saved_query_byte_limit: BYTE_LIMIT, +}; +const WITH_PASSWORDS: ImportRequest = ImportRequest { + include_passwords: true, + ..CONNECTIONS_ONLY +}; +const WITH_SAVED_QUERIES: ImportRequest = ImportRequest { + include_saved_queries: true, + ..CONNECTIONS_ONLY +}; + +// The schema and rows of the macOS tests; `more_connections.sql` runs after `queries.sql`. +const FOLDERS_DATABASE: &[&str] = &["connections.sql", "queries.sql"]; +const FULL_DATABASE: &[&str] = &["connections.sql", "queries.sql", "more_connections.sql"]; + +#[derive(Default)] +struct RecordingKeychain { + reads: AtomicUsize, +} + +impl ForeignKeychain for RecordingKeychain { + fn read_password(&self, _service: &str, _account: Option<&str>) -> KeychainRead { + self.reads.fetch_add(1, Ordering::SeqCst); + KeychainRead::Found(SecretString::from("from the keychain".to_owned())) + } +} + +#[derive(Default)] +struct TestHost { + keychain: Arc, + installed: Mutex>, +} + +impl ForeignHost for TestHost { + fn installed(&self, _app: ForeignApp) -> Option { + *self.installed.lock().expect("the flag") + } + + fn keychain(&self) -> Option> { + Some(self.keychain.clone()) + } +} + +struct Home { + dir: TempDir, + host: Arc, +} + +impl Home { + fn new() -> Self { + Self { + dir: tempfile::tempdir().expect("a home"), + host: Arc::new(TestHost::default()), + } + } + + fn path(&self, relative: &str) -> PathBuf { + self.dir.path().join(relative) + } + + fn importer(&self, platform: ForeignPlatform) -> ForeignImporter { + self.importer_with(ForeignAppDirs::conventional(platform, self.dir.path())) + } + + fn importer_with(&self, dirs: ForeignAppDirs) -> ForeignImporter { + ForeignImporter::new(ForeignApp::BeekeeperStudio, dirs, self.host.clone()) + } +} + +const MAC_FOLDER: &str = "Library/Application Support/beekeeper-studio"; + +fn fixture(name: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/beekeeper") + .join(name); + fs::read_to_string(path).expect("a fixture") +} + +fn make_app_db(folder: &Path, scripts: &[&str]) { + fs::create_dir_all(folder).expect("the data folder"); + let sql: Vec = scripts.iter().map(|name| fixture(name)).collect(); + execute(&folder.join("app.db"), &sql.join("\n")); +} + +// Beekeeper writes the key with no final newline; the repository's editor settings add one to the fixture. +fn write_key_file(folder: &Path) { + fs::write(folder.join(".key"), fixture(".key").trim_end()).expect("the key file"); +} + +fn execute(path: &Path, sql: &str) { + let path = CString::new(path.to_str().expect("a UTF-8 path")).expect("a path"); + let sql = CString::new(sql).expect("SQL"); + let flags = libsqlite3_sys::SQLITE_OPEN_READWRITE | libsqlite3_sys::SQLITE_OPEN_CREATE; + let mut db = ptr::null_mut(); + // SAFETY: both strings are NUL-terminated and outlive the calls, the handle is closed once, and the exec + // passes no callback. + let (opened, executed) = unsafe { + let opened = libsqlite3_sys::sqlite3_open_v2(path.as_ptr(), &mut db, flags, ptr::null()); + let executed = libsqlite3_sys::sqlite3_exec(db, sql.as_ptr(), None, ptr::null_mut(), ptr::null_mut()); + libsqlite3_sys::sqlite3_close(db); + (opened, executed) + }; + assert_eq!( + (opened, executed), + (libsqlite3_sys::SQLITE_OK, libsqlite3_sys::SQLITE_OK) + ); +} + +fn collect(importer: &ForeignImporter, request: &ImportRequest) -> CollectedImport { + importer + .collect_blocking(request, &CancellationToken::new()) + .expect("an import") +} + +fn mac_home(scripts: &[&str]) -> (Home, ForeignImporter) { + let home = Home::new(); + make_app_db(&home.path(MAC_FOLDER), scripts); + let importer = home.importer(ForeignPlatform::MacOs); + (home, importer) +} + +fn names(collected: &CollectedImport) -> Vec<&str> { + collected + .connections + .iter() + .map(|connection| connection.settings.name.as_str()) + .collect() +} + +fn query_names(collected: &CollectedImport) -> Vec<&str> { + collected + .saved_queries + .iter() + .map(|query| query.name.as_str()) + .collect() +} + +fn secret(value: Option<&SecretString>) -> Option<&str> { + value.map(|value| value.expose_secret()) +} + +#[test] +fn local_workspace_connections_import_keyed_by_their_beekeeper_id() { + let (_home, importer) = mac_home(FOLDERS_DATABASE); + + let collected = collect(&importer, &CONNECTIONS_ONLY); + + assert_eq!(collected.app, ForeignApp::BeekeeperStudio); + assert_eq!(names(&collected), ["Shop"]); + let shop = &collected.connections[0]; + assert_eq!(shop.reference, "1"); + assert_eq!(shop.group_path, ["Production"]); + assert_eq!(shop.settings.engine, "PostgreSQL"); + assert_eq!(shop.settings.host, "db.example.com"); + assert_eq!(shop.settings.port, 5432); + assert_eq!(shop.settings.database, "shop"); + assert_eq!(shop.settings.username, "app"); + assert!(shop.settings.ssh.is_none()); + assert!(shop.settings.ssl.is_none()); + assert!(shop.credentials.is_none()); + assert!(collected.saved_queries.is_empty()); + assert_eq!(collected.passwords, PasswordOutcome::NotRequested); +} + +#[test] +fn a_missing_app_db_is_file_not_found() { + let home = Home::new(); + let outcome = home + .importer(ForeignPlatform::MacOs) + .collect_blocking(&WITH_SAVED_QUERIES, &CancellationToken::new()); + assert!(matches!( + outcome, + Err(ForeignImportError::FileNotFound { + app: ForeignApp::BeekeeperStudio + }) + )); +} + +#[test] +fn a_file_that_is_not_a_database_is_an_unsupported_format() { + let home = Home::new(); + let folder = home.path(MAC_FOLDER); + fs::create_dir_all(&folder).expect("the data folder"); + fs::write(folder.join("app.db"), "not a database").expect("a file"); + + let outcome = home + .importer(ForeignPlatform::MacOs) + .collect_blocking(&CONNECTIONS_ONLY, &CancellationToken::new()); + + assert!(matches!(outcome, Err(ForeignImportError::UnsupportedFormat { .. }))); + assert_eq!( + home.importer(ForeignPlatform::MacOs).inventory_blocking(), + Inventory::default() + ); +} + +#[test] +fn saved_queries_keep_their_folder_tree_under_the_app_folder() { + let (_home, importer) = mac_home(FOLDERS_DATABASE); + + let collected = collect(&importer, &WITH_SAVED_QUERIES); + + let text: Vec<&str> = collected + .saved_queries + .iter() + .filter(|query| matches!(query.content, SavedQueryContent::Text(_))) + .map(|query| query.name.as_str()) + .collect(); + assert_eq!(text, ["Top customers", "Daily active users"]); + + let daily = &collected.saved_queries[1]; + assert_eq!( + daily.content, + SavedQueryContent::Text("select count(*) from sessions;".to_owned()) + ); + assert_eq!(daily.connection, None); + assert_eq!(daily.keyword, None); + assert_eq!(daily.folder_path, ["Beekeeper Studio", "Reports", "Daily"]); + assert!(daily.suggested); + + let top = &collected.saved_queries[0]; + assert_eq!( + top.content, + SavedQueryContent::Text("select * from customers where region = ${region};".to_owned()) + ); + assert_eq!(top.folder_path, ["Beekeeper Studio"]); +} + +#[test] +fn the_seeded_demo_query_and_blank_queries_are_skipped() { + let (_home, importer) = mac_home(FOLDERS_DATABASE); + + let collected = collect(&importer, &WITH_SAVED_QUERIES); + + assert_eq!(query_names(&collected), ["Top customers", "Daily active users", "Huge"]); +} + +#[test] +fn a_query_over_the_byte_limit_is_listed_as_oversized_in_its_folder() { + let (_home, importer) = mac_home(FOLDERS_DATABASE); + + let collected = collect(&importer, &WITH_SAVED_QUERIES); + + let huge = collected + .saved_queries + .iter() + .find(|query| query.name == "Huge") + .expect("the query"); + assert_eq!(huge.content, SavedQueryContent::Oversized { byte_count: 900_001 }); + assert_eq!(huge.folder_path, ["Beekeeper Studio", "Reports"]); + assert_eq!(huge.connection, None); + assert!(!huge.suggested); +} + +#[test] +fn a_query_at_the_byte_limit_imports_its_text() { + let (_home, importer) = mac_home(FOLDERS_DATABASE); + let request = ImportRequest { + saved_query_byte_limit: 900_001, + ..WITH_SAVED_QUERIES + }; + + let collected = collect(&importer, &request); + + let huge = collected + .saved_queries + .iter() + .find(|query| query.name == "Huge") + .expect("the query"); + assert_eq!(huge.content, SavedQueryContent::Text("x".repeat(900_001))); +} + +#[test] +fn an_older_database_without_query_folders_imports_its_queries_flat() { + let (_home, importer) = mac_home(&["connections.sql", "queries_without_folders.sql"]); + + let collected = collect(&importer, &WITH_SAVED_QUERIES); + + assert_eq!(query_names(&collected), ["Orders"]); + assert_eq!(collected.saved_queries[0].folder_path, ["Beekeeper Studio"]); +} + +#[test] +fn a_database_without_saved_queries_imports_its_connections() { + let (_home, importer) = mac_home(&["connections.sql"]); + + let collected = collect(&importer, &WITH_SAVED_QUERIES); + + assert_eq!(collected.connections.len(), 1); + assert!(collected.saved_queries.is_empty()); + assert_eq!( + importer.inventory_blocking(), + Inventory { + connections: 1, + saved_queries: 0 + } + ); +} + +#[test] +fn inventory_counts_connections_and_queries_without_the_demo_or_blank_rows() { + let (_home, importer) = mac_home(FOLDERS_DATABASE); + + assert_eq!( + importer.inventory_blocking(), + Inventory { + connections: 1, + saved_queries: 3 + } + ); +} + +#[test] +fn cloud_workspace_queries_are_left_out() { + let (_home, importer) = mac_home(FULL_DATABASE); + + let collected = collect(&importer, &WITH_SAVED_QUERIES); + + assert!(!query_names(&collected).contains(&"Cloud report")); + // The two rows without a connection type are not imported, so they are not counted either. + assert_eq!( + importer.inventory_blocking(), + Inventory { + connections: 4, + saved_queries: 3 + } + ); +} + +#[test] +fn saved_queries_import_into_a_folder_named_after_the_app() { + assert_eq!( + ForeignApp::BeekeeperStudio.saved_query_support(), + SavedQuerySupport::GlobalFolder + ); +} + +#[test] +fn the_app_is_named_and_reads_passwords_from_its_own_files() { + let app = ForeignApp::BeekeeperStudio; + assert_eq!(app.id(), "beekeeperstudio"); + assert_eq!(ForeignApp::from_id("beekeeperstudio"), Some(app)); + assert_eq!(app.display_name(), "Beekeeper Studio"); + assert_eq!(app.password_source(), PasswordSource::AppFiles); + assert!(app.picked_file_extensions().is_none()); +} + +#[test] +fn an_empty_home_has_nothing_on_any_platform() { + let home = Home::new(); + for platform in [ForeignPlatform::MacOs, ForeignPlatform::Linux, ForeignPlatform::Windows] { + let importer = home.importer(platform); + assert_eq!(importer.inventory_blocking(), Inventory::default()); + assert!(!importer.is_available_blocking()); + } +} + +#[test] +fn the_app_counts_as_available_when_its_database_is_there() { + let (home, importer) = mac_home(&["connections.sql"]); + assert!(importer.is_available_blocking()); + *home.host.installed.lock().expect("the flag") = Some(false); + assert!(!importer.is_available_blocking()); +} + +#[test] +fn passwords_decrypt_with_the_key_from_the_key_file() { + let (home, importer) = mac_home(FULL_DATABASE); + write_key_file(&home.path(MAC_FOLDER)); + + let collected = collect(&importer, &WITH_PASSWORDS); + + assert_eq!(collected.passwords, PasswordOutcome::Read); + let shop = collected.connections[0].credentials.as_ref().expect("Shop's password"); + assert_eq!(secret(shop.password.as_ref()), Some("hunter2")); + assert_eq!(secret(shop.ssh_password.as_ref()), None); + let tunnelled = collected.connections[1] + .credentials + .as_ref() + .expect("the tunnel's passwords"); + assert_eq!(secret(tunnelled.password.as_ref()), Some("s3cret")); + assert_eq!(secret(tunnelled.ssh_password.as_ref()), Some("ssh-pass")); + assert_eq!(secret(tunnelled.key_passphrase.as_ref()), Some("key-phrase")); + assert!(collected.connections[2].credentials.is_none()); + assert_eq!(home.host.keychain.reads.load(Ordering::SeqCst), 0); +} + +#[test] +fn without_a_key_file_connections_import_without_passwords() { + let (_home, importer) = mac_home(FULL_DATABASE); + + let collected = collect(&importer, &WITH_PASSWORDS); + + assert_eq!(collected.passwords, PasswordOutcome::Read); + assert!( + collected + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); +} + +#[test] +fn passwords_are_not_read_unless_asked_for() { + let (home, importer) = mac_home(FULL_DATABASE); + write_key_file(&home.path(MAC_FOLDER)); + + let collected = collect(&importer, &CONNECTIONS_ONLY); + + assert_eq!(collected.passwords, PasswordOutcome::NotRequested); + assert!( + collected + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); +} + +#[test] +fn ssh_ssl_colors_and_defaults_map_as_on_macos() { + let (_home, importer) = mac_home(FULL_DATABASE); + + let collected = collect(&importer, &CONNECTIONS_ONLY); + + assert_eq!(names(&collected), ["Shop", "Tunnelled", "Untitled", "Lake"]); + + let tunnelled = &collected.connections[1]; + assert_eq!(tunnelled.settings.engine, "MySQL"); + assert_eq!(tunnelled.settings.port, 3307); + assert_eq!(tunnelled.settings.color, Some(ForeignColor::Gray)); + assert!(tunnelled.group_path.is_empty()); + let ssh = tunnelled.settings.ssh.as_ref().expect("a tunnel"); + assert_eq!(ssh.host, "bastion.example.com"); + assert_eq!(ssh.port, Some(2222)); + assert_eq!(ssh.username, "deploy"); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, "~/.ssh/id_ed25519"); + assert_eq!(ssh.jump_hosts.len(), 1); + let jump = &ssh.jump_hosts[0]; + assert_eq!( + (jump.host.as_str(), jump.port, jump.username.as_str()), + ("jump.example.com", Some(22), "hop") + ); + assert_eq!(jump.auth, ForeignSshAuth::Agent); + assert_eq!(jump.private_key_path, ""); + let ssl = tunnelled.settings.ssl.as_ref().expect("TLS"); + assert_eq!(ssl.mode, ForeignSslMode::VerifyIdentity); + assert_eq!(ssl.ca_certificate_path.as_deref(), Some("/certs/ca.pem")); + assert_eq!(ssl.client_certificate_path.as_deref(), Some("/certs/client.pem")); + assert_eq!(ssl.client_key_path.as_deref(), Some("/certs/client.key")); + + let untitled = &collected.connections[2]; + assert_eq!(untitled.reference, "4"); + assert_eq!(untitled.settings.host, "localhost"); + assert_eq!(untitled.settings.engine, "SQL Server"); + assert_eq!(untitled.settings.port, 1433); + assert_eq!(untitled.settings.color, None); + let ssh = untitled.settings.ssh.as_ref().expect("a tunnel"); + assert_eq!( + (ssh.host.as_str(), ssh.port, ssh.auth), + ("gateway", None, ForeignSshAuth::Password) + ); + assert!(ssh.jump_hosts.is_empty()); + assert_eq!( + untitled.settings.ssl.as_ref().map(|ssl| ssl.mode), + Some(ForeignSslMode::Required) + ); + + let lake = &collected.connections[3]; + assert_eq!(lake.settings.engine, "acmedb"); + assert_eq!(lake.settings.port, 8000); + let ssl = lake.settings.ssl.as_ref().expect("TLS"); + assert_eq!(ssl.mode, ForeignSslMode::Required); + assert_eq!(ssl.ca_certificate_path, None); +} + +#[test] +fn a_cancelled_import_stops() { + let (_home, importer) = mac_home(FULL_DATABASE); + let cancel = CancellationToken::new(); + cancel.cancel(); + + let outcome = importer.collect_blocking(&WITH_SAVED_QUERIES, &cancel); + + assert!(matches!(outcome, Err(ForeignImportError::Cancelled))); +} + +#[test] +fn windows_reads_the_roaming_app_data_folder() { + let home = Home::new(); + make_app_db(&home.path("AppData/Roaming/beekeeper-studio"), &["connections.sql"]); + + let collected = collect(&home.importer(ForeignPlatform::Windows), &CONNECTIONS_ONLY); + + assert_eq!(names(&collected), ["Shop"]); +} + +#[test] +fn linux_reads_each_package_folder() { + let folders = [ + ".config/beekeeper-studio", + ".var/app/io.beekeeperstudio.Studio/config/beekeeper-studio", + ".var/app/io.beekeeperstudio.desktop/config/beekeeper-studio", + "snap/beekeeper-studio/current/.config/beekeeper-studio", + ]; + for folder in folders { + let home = Home::new(); + make_app_db(&home.path(folder), &["connections.sql"]); + write_key_file(&home.path(folder)); + + let collected = collect(&home.importer(ForeignPlatform::Linux), &WITH_PASSWORDS); + + assert_eq!(names(&collected), ["Shop"], "{folder}"); + } +} + +#[test] +fn linux_reads_the_host_config_folder_when_it_is_not_the_default() { + let home = Home::new(); + make_app_db(&home.path("xdg/beekeeper-studio"), &["connections.sql"]); + let dirs = ForeignAppDirs { + config: home.path("xdg"), + ..ForeignAppDirs::conventional(ForeignPlatform::Linux, home.dir.path()) + }; + + assert_eq!(names(&collect(&home.importer_with(dirs), &CONNECTIONS_ONLY)), ["Shop"]); +} + +#[test] +fn linux_uses_the_copy_saved_to_last_when_several_packages_left_one() { + let home = Home::new(); + let native = home.path(".config/beekeeper-studio"); + let flathub = home.path(".var/app/io.beekeeperstudio.Studio/config/beekeeper-studio"); + make_app_db(&native, &["connections.sql"]); + make_app_db(&flathub, &["connections.sql"]); + execute( + &flathub.join("app.db"), + "UPDATE saved_connection SET name = 'Shop on Flathub' WHERE id = 1;", + ); + let set_age = |folder: &Path, seconds: u64| { + let file = File::options() + .write(true) + .open(folder.join("app.db")) + .expect("the database"); + file.set_modified(SystemTime::now() - Duration::from_secs(seconds)) + .expect("a time"); + }; + let importer = home.importer(ForeignPlatform::Linux); + + set_age(&native, 600); + set_age(&flathub, 60); + assert_eq!(names(&collect(&importer, &CONNECTIONS_ONLY)), ["Shop on Flathub"]); + + set_age(&native, 30); + assert_eq!(names(&collect(&importer, &CONNECTIONS_ONLY)), ["Shop"]); +} diff --git a/core/crates/foreign-import/tests/datagrip.rs b/core/crates/foreign-import/tests/datagrip.rs new file mode 100644 index 0000000000..d5a73e9caa --- /dev/null +++ b/core/crates/foreign-import/tests/datagrip.rs @@ -0,0 +1,569 @@ +#![expect( + clippy::expect_used, + clippy::panic, + reason = "the setup helpers fail a test the way the test functions themselves do" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use secrecy::{ExposeSecret, SecretString}; +use tablepro_foreign_import::{ + CollectedConnection, CollectedImport, CollectedSavedQuery, ForeignApp, ForeignAppDirs, ForeignHost, + ForeignImportError, ForeignImporter, ForeignKeychain, ForeignPlatform, ForeignSshAuth, ForeignSslMode, + ImportRequest, Inventory, KeychainRead, PasswordOutcome, PasswordSource, PlainHost, SavedQueryContent, + SavedQuerySupport, +}; +use tokio_util::sync::CancellationToken; + +const SHOP: &str = "11111111-1111-4111-8111-111111111111"; +const MONGO: &str = "33333333-3333-4333-8333-333333333333"; + +#[derive(Default)] +struct Keychain { + secrets: HashMap, + deny_all: bool, + reads: Mutex)>>, +} + +impl ForeignKeychain for Keychain { + fn read_password(&self, service: &str, account: Option<&str>) -> KeychainRead { + self.reads + .lock() + .expect("a lock") + .push((service.to_owned(), account.map(str::to_owned))); + if self.deny_all { + return KeychainRead::Denied; + } + self.secrets.get(service).cloned().unwrap_or(KeychainRead::NotFound) + } +} + +impl Keychain { + fn reads(&self) -> Vec<(String, Option)> { + self.reads.lock().expect("a lock").clone() + } +} + +struct Host { + keychain: Arc, +} + +impl ForeignHost for Host { + fn keychain(&self) -> Option> { + Some(self.keychain.clone()) + } +} + +fn fixture(scenario: &str, platform: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/datagrip") + .join(scenario) + .join(platform) +} + +fn importer(dirs: ForeignAppDirs, host: Arc) -> ForeignImporter { + ForeignImporter::new(ForeignApp::DataGrip, dirs, host) +} + +fn mac(scenario: &str, keychain: Arc) -> ForeignImporter { + importer( + ForeignAppDirs::conventional(ForeignPlatform::MacOs, &fixture(scenario, "macos")), + Arc::new(Host { keychain }), + ) +} + +fn plain(home: &Path, platform: ForeignPlatform) -> ForeignImporter { + importer(ForeignAppDirs::conventional(platform, home), Arc::new(PlainHost)) +} + +fn request(include_passwords: bool, include_saved_queries: bool) -> ImportRequest { + ImportRequest { + include_passwords, + include_saved_queries, + saved_query_byte_limit: 1_000_000, + } +} + +fn collect(importer: &ForeignImporter, request: ImportRequest) -> CollectedImport { + importer + .collect_blocking(&request, &CancellationToken::new()) + .expect("an import") +} + +fn connection<'a>(import: &'a CollectedImport, name: &str) -> &'a CollectedConnection { + import + .connections + .iter() + .find(|connection| connection.settings.name == name) + .expect("a connection") +} + +fn names(import: &CollectedImport) -> Vec<&str> { + import + .connections + .iter() + .map(|connection| connection.settings.name.as_str()) + .collect() +} + +fn query<'a>(import: &'a CollectedImport, name: &str, connection: Option<&str>) -> Option<&'a CollectedSavedQuery> { + import + .saved_queries + .iter() + .find(|query| query.name == name && query.connection.as_deref() == connection) +} + +fn text(query: &CollectedSavedQuery) -> &str { + match &query.content { + SavedQueryContent::Text(text) => text, + SavedQueryContent::Oversized { .. } => panic!("expected text"), + } +} + +fn secret(value: Option<&SecretString>) -> Option<&str> { + value.map(ExposeSecret::expose_secret) +} + +fn connections_import(keychain: Arc) -> CollectedImport { + collect(&mac("connections", keychain), request(false, false)) +} + +#[test] +fn datagrip_reads_passwords_from_a_keychain_and_consoles_as_saved_queries() { + assert_eq!(ForeignApp::from_id("datagrip"), Some(ForeignApp::DataGrip)); + assert_eq!(ForeignApp::DataGrip.display_name(), "DataGrip"); + assert_eq!(ForeignApp::DataGrip.password_source(), PasswordSource::Keychain); + assert_eq!(ForeignApp::DataGrip.saved_query_support(), SavedQuerySupport::Consoles); +} + +#[test] +fn an_empty_home_has_nothing_and_an_import_finds_no_files() { + let home = tempfile::tempdir().expect("a temp dir"); + for platform in [ForeignPlatform::MacOs, ForeignPlatform::Linux, ForeignPlatform::Windows] { + let importer = plain(home.path(), platform); + assert_eq!(importer.inventory_blocking(), Inventory::default()); + assert!(!importer.is_available_blocking()); + let outcome = importer.collect_blocking(&request(true, true), &CancellationToken::new()); + assert!(matches!( + outcome, + Err(ForeignImportError::FileNotFound { + app: ForeignApp::DataGrip + }) + )); + } +} + +#[test] +fn the_inventory_counts_unique_data_sources() { + let importer = mac("connections", Arc::default()); + assert!(importer.is_available_blocking()); + assert_eq!( + importer.inventory_blocking(), + Inventory { + connections: 13, + saved_queries: 0 + } + ); +} + +#[test] +fn driver_refs_map_to_engines() { + let import = connections_import(Arc::default()); + for (name, engine) in [ + ("my", "MySQL"), + ("pg", "PostgreSQL"), + ("ms", "SQL Server"), + ("or", "Oracle"), + ("lt", "SQLite"), + ] { + assert_eq!(connection(&import, name).settings.engine, engine, "{name}"); + } +} + +#[test] +fn host_port_and_database_come_from_the_jdbc_url() { + let import = connections_import(Arc::default()); + let settings = &connection(&import, "my").settings; + assert_eq!(settings.host, "db.example.com"); + assert_eq!(settings.port, 3307); + assert_eq!(settings.database, "shop"); + assert_eq!(settings.username, "root"); +} + +#[test] +fn a_url_without_a_port_takes_the_engines_default_port() { + let import = connections_import(Arc::default()); + assert_eq!(connection(&import, "pg").settings.port, 5432); +} + +#[test] +fn a_portless_redshift_url_takes_the_redshift_default_port() { + let import = connections_import(Arc::default()); + let settings = &connection(&import, "rs").settings; + assert_eq!(settings.engine, "Redshift"); + assert_eq!(settings.port, 5439); +} + +#[test] +fn sqlite_keeps_its_file_path_as_the_database() { + let import = connections_import(Arc::default()); + let settings = &connection(&import, "lt").settings; + assert_eq!(settings.engine, "SQLite"); + assert_eq!(settings.database, "/Users/me/app.db"); +} + +#[test] +fn ssh_joins_the_config_from_the_local_file_infers_key_auth_and_expands_user_home() { + let home = fixture("connections", "macos"); + let import = connections_import(Arc::default()); + let settings = &connection(&import, "ssh_key").settings; + assert_eq!(settings.username, "appuser"); + let ssh = settings.ssh.as_ref().expect("ssh"); + assert_eq!(ssh.host, "bastion.example.com"); + assert_eq!(ssh.port, Some(2222)); + assert_eq!(ssh.username, "deploy"); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, format!("{}/.ssh/id_ed25519", home.display())); +} + +#[test] +fn an_explicit_password_auth_wins_over_a_key_path() { + let import = connections_import(Arc::default()); + let ssh = connection(&import, "ssh_password").settings.ssh.as_ref().expect("ssh"); + assert_eq!(ssh.auth, ForeignSshAuth::Password); + assert_eq!(ssh.private_key_path, ""); +} + +#[test] +fn disabled_ssh_properties_give_no_tunnel() { + let import = connections_import(Arc::default()); + assert_eq!(connection(&import, "ssh_off").settings.ssh, None); +} + +#[test] +fn an_ssh_password_is_imported_from_c_kdbx() { + let import = collect(&mac("connections", Arc::default()), request(true, false)); + let credentials = connection(&import, "ssh_kdbx") + .credentials + .as_ref() + .expect("credentials"); + assert_eq!(secret(credentials.ssh_password.as_ref()), Some("ssh-pw")); + assert_eq!(import.passwords, PasswordOutcome::Read); +} + +#[test] +fn a_user_name_from_the_local_file_fills_the_shared_one() { + let import = connections_import(Arc::default()); + assert_eq!(connection(&import, "user_local").settings.username, "postgres"); +} + +#[test] +fn ssl_config_comes_from_the_local_file_with_user_home_expanded() { + let home = fixture("connections", "macos"); + let import = connections_import(Arc::default()); + let ssl = connection(&import, "tls").settings.ssl.as_ref().expect("ssl"); + assert_eq!(ssl.mode, ForeignSslMode::VerifyIdentity); + let expected = |file: &str| Some(format!("{}/certs/{file}", home.display())); + assert_eq!(ssl.ca_certificate_path, expected("ca.pem")); + assert_eq!(ssl.client_certificate_path, expected("client.crt")); + assert_eq!(ssl.client_key_path, expected("client.key")); +} + +#[test] +fn the_group_name_becomes_a_group_and_the_uuid_the_reference() { + let import = connections_import(Arc::default()); + let first = &import.connections[0]; + assert_eq!(first.settings.name, "my"); + assert_eq!(first.group_path, ["Production"]); + assert_eq!(first.reference, "1"); +} + +#[test] +fn data_sources_sharing_a_uuid_import_once() { + let import = connections_import(Arc::default()); + let copies: Vec<&CollectedConnection> = import + .connections + .iter() + .filter(|connection| connection.reference.starts_with("dup")) + .collect(); + assert_eq!(copies.len(), 1); + assert_eq!(copies[0].settings.name, "A copy"); +} + +#[test] +fn the_keychain_is_asked_by_service_name_alone_before_c_kdbx() { + let keychain = Arc::new(Keychain { + secrets: HashMap::from([( + "IntelliJ Platform DB \u{2014} 1".to_owned(), + KeychainRead::Found(SecretString::from("kc-secret")), + )]), + ..Keychain::default() + }); + let import = collect(&mac("connections", keychain.clone()), request(true, false)); + let password = |name: &str| { + secret( + connection(&import, name) + .credentials + .as_ref() + .and_then(|credentials| credentials.password.as_ref()), + ) + .map(str::to_owned) + }; + assert_eq!(password("my").as_deref(), Some("kc-secret")); + assert_eq!(password("pg").as_deref(), Some("pg-from-kdbx")); + assert_eq!(password("ms"), None); + assert_eq!(import.passwords, PasswordOutcome::Read); + let reads = keychain.reads(); + assert_eq!(reads[0], ("IntelliJ Platform DB \u{2014} 1".to_owned(), None)); + assert!(reads.iter().all(|(_, account)| account.is_none())); + assert!(reads.contains(&( + "IntelliJ Platform SshConfigPassphrase \u{2014} bastion.example.com:2222 SSH1".to_owned(), + None + ))); +} + +#[test] +fn a_refused_keychain_stops_reading_passwords() { + let keychain = Arc::new(Keychain { + deny_all: true, + ..Keychain::default() + }); + let import = collect(&mac("connections", keychain.clone()), request(true, false)); + assert_eq!(import.passwords, PasswordOutcome::Stopped); + assert!( + import + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); + assert_eq!(keychain.reads().len(), 1); +} + +#[test] +fn no_password_is_read_unless_requested() { + let keychain = Arc::new(Keychain::default()); + let import = collect(&mac("connections", keychain.clone()), request(false, false)); + assert_eq!(import.passwords, PasswordOutcome::NotRequested); + assert!(keychain.reads().is_empty()); + assert!( + import + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); +} + +#[test] +fn a_cancelled_import_stops() { + let cancel = CancellationToken::new(); + cancel.cancel(); + let outcome = mac("connections", Arc::default()).collect_blocking(&request(true, true), &cancel); + assert!(matches!(outcome, Err(ForeignImportError::Cancelled))); +} + +#[test] +fn consoles_come_from_each_data_sources_newest_config_dir() { + let import = collect(&mac("consoles", Arc::default()), request(false, true)); + let mut shop: Vec<&str> = import + .saved_queries + .iter() + .filter(|query| query.connection.as_deref() == Some(SHOP)) + .map(|query| query.name.as_str()) + .collect(); + shop.sort_unstable(); + assert_eq!( + shop, + [ + "Console_7", + "console", + "console_1", + "dg_sample_monthly", + "dg_sample_report" + ] + ); + let report = query(&import, "dg_sample_report", Some(SHOP)).expect("a report"); + assert_eq!(text(report), "select * from dg_sample_items;"); + assert!(query(&import, "dg_sample_legacy", Some(SHOP)).is_none()); + let monthly = query(&import, "dg_sample_monthly", Some(SHOP)).expect("a monthly report"); + assert_eq!(monthly.folder_path, ["reports"]); +} + +#[test] +fn default_console_names_start_unchecked_and_user_named_ones_checked() { + let import = collect(&mac("consoles", Arc::default()), request(false, true)); + for name in ["console", "console_1", "Console_7"] { + assert!( + !query(&import, name, Some(SHOP)).expect("a console").suggested, + "{name}" + ); + } + for name in ["dg_sample_report", "dg_sample_monthly"] { + assert!(query(&import, name, Some(SHOP)).expect("a console").suggested, "{name}"); + } +} + +#[test] +fn js_consoles_are_read_only_for_a_mongodb_data_source() { + let import = collect(&mac("consoles", Arc::default()), request(false, true)); + let mongo = query(&import, "console", Some(MONGO)).expect("a mongo console"); + assert_eq!(text(mongo), "db.dg_sample_items.find()"); + assert!(!mongo.suggested); + assert!(query(&import, "script", Some(SHOP)).is_none()); +} + +#[test] +fn a_data_source_that_imports_no_connection_keeps_its_consoles_global_and_unchecked() { + let import = collect(&mac("consoles", Arc::default()), request(false, true)); + assert_eq!(names(&import), ["dg_sample_shop", "dg_sample_mongo"]); + let unmapped = query(&import, "dg_unmapped_query", None).expect("an unmapped console"); + assert_eq!(unmapped.folder_path, ["DataGrip"]); + assert!(!unmapped.suggested); +} + +#[test] +fn orphan_folders_history_scratches_hidden_blank_and_foreign_files_are_skipped() { + let import = collect(&mac("consoles", Arc::default()), request(false, true)); + let found: Vec<&str> = import.saved_queries.iter().map(|query| query.name.as_str()).collect(); + for skipped in ["dg_sample_orphan", "scratch", "console_3", "notes", ".hidden_console"] { + assert!(!found.contains(&skipped), "{skipped}"); + } + assert!(!import.saved_queries.iter().any(|query| text(query).contains("history"))); + assert_eq!(import.saved_queries.len(), 7); +} + +#[test] +fn a_data_source_uuid_that_is_not_a_uuid_never_becomes_a_console_path() { + let import = collect(&mac("escape", Arc::default()), request(false, true)); + assert_eq!(names(&import), ["dg_sample_escape"]); + assert!(import.saved_queries.is_empty()); +} + +#[test] +fn a_custom_default_file_name_decides_which_consoles_are_auto_named() { + let import = collect(&mac("scratches-name", Arc::default()), request(false, true)); + assert!(!query(&import, "dg_sample_q", Some(SHOP)).expect("a console").suggested); + assert!( + !query(&import, "dg_sample_q_2", Some(SHOP)) + .expect("a console") + .suggested + ); + assert!(query(&import, "console", Some(SHOP)).expect("a console").suggested); +} + +#[test] +fn no_consoles_are_read_unless_saved_queries_are_requested() { + let import = collect(&mac("consoles", Arc::default()), request(false, false)); + assert!(import.saved_queries.is_empty()); +} + +#[test] +fn the_inventory_counts_consoles_that_have_text() { + assert_eq!( + mac("consoles", Arc::default()).inventory_blocking(), + Inventory { + connections: 2, + saved_queries: 7 + } + ); +} + +#[test] +fn linux_reads_the_config_folder_projects_recent_projects_and_the_flatpak_folder() { + let import = collect( + &plain(&fixture("roots", "linux"), ForeignPlatform::Linux), + request(false, false), + ); + assert_eq!( + names(&import), + [ + "shared_new", + "flatpak_only", + "pg_native", + "default_project", + "project_source" + ] + ); + assert_eq!(connection(&import, "shared_new").settings.host, "new.example.com"); + let project = &connection(&import, "project_source").settings; + assert_eq!(project.engine, "MariaDB"); + assert_eq!(project.username, "shop_user"); +} + +#[test] +fn a_custom_xdg_config_folder_is_searched_first_and_wins_a_tie() { + let home = fixture("roots", "linux"); + let dirs = ForeignAppDirs { + config: home.join("xdg-config"), + ..ForeignAppDirs::conventional(ForeignPlatform::Linux, &home) + }; + let import = collect(&importer(dirs, Arc::new(PlainHost)), request(false, false)); + assert_eq!( + names(&import), + [ + "shared_xdg", + "flatpak_only", + "pg_native", + "default_project", + "project_source" + ] + ); +} + +#[test] +fn passwords_on_linux_stay_in_the_keyring_without_a_keepass_file() { + let import = collect( + &plain(&fixture("roots", "linux"), ForeignPlatform::Linux), + request(true, false), + ); + assert_eq!(import.passwords, PasswordOutcome::LeftInKeychain); + assert!( + import + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); +} + +#[test] +fn a_legacy_raw_pdb_pwd_opens_c_kdbx_on_linux() { + let import = collect( + &plain(&fixture("legacy-key", "linux"), ForeignPlatform::Linux), + request(true, false), + ); + assert_eq!(import.passwords, PasswordOutcome::Read); + let credentials = connection(&import, "legacy_mysql") + .credentials + .as_ref() + .expect("credentials"); + assert_eq!(secret(credentials.password.as_ref()), Some("legacy-secret")); +} + +#[test] +fn windows_reads_the_roaming_jetbrains_folder() { + let import = collect( + &plain(&fixture("roaming", "windows"), ForeignPlatform::Windows), + request(false, false), + ); + let settings = &connection(&import, "win_sql").settings; + assert_eq!(settings.engine, "SQL Server"); + assert_eq!(settings.host, "sql.example.com"); + assert_eq!(settings.database, "Sales"); + assert_eq!(import.passwords, PasswordOutcome::NotRequested); +} + +#[test] +fn a_crypt_32_main_key_on_windows_stops_reading_passwords() { + let import = collect( + &plain(&fixture("roaming", "windows"), ForeignPlatform::Windows), + request(true, false), + ); + assert_eq!(import.passwords, PasswordOutcome::Stopped); + assert!( + import + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); +} diff --git a/core/crates/foreign-import/tests/dbeaver.rs b/core/crates/foreign-import/tests/dbeaver.rs new file mode 100644 index 0000000000..40391a1791 --- /dev/null +++ b/core/crates/foreign-import/tests/dbeaver.rs @@ -0,0 +1,638 @@ +#![expect( + clippy::expect_used, + clippy::panic, + reason = "the setup helpers fail a test the way the test functions themselves do" +)] + +use std::collections::BTreeSet; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use secrecy::ExposeSecret; +use tablepro_foreign_import::{ + CollectedConnection, CollectedImport, CollectedSavedQuery, ForeignApp, ForeignAppDirs, ForeignColor, ForeignHost, + ForeignImportError, ForeignImporter, ForeignPlatform, ForeignSshAuth, ForeignSslMode, ImportRequest, Inventory, + PasswordOutcome, PlainHost, SavedQueryContent, +}; +use tokio_util::sync::CancellationToken; + +const SYNC_LIMIT: u64 = 900_000; +const CONNECTIONS_ONLY: ImportRequest = request(false, false); +const WITH_PASSWORDS: ImportRequest = request(true, false); +const WITH_SAVED_QUERIES: ImportRequest = request(false, true); +const MACOS_CONNECTION_COUNT: usize = 48; + +const fn request(include_passwords: bool, include_saved_queries: bool) -> ImportRequest { + ImportRequest { + include_passwords, + include_saved_queries, + saved_query_byte_limit: SYNC_LIMIT, + } +} + +struct InstalledHost(Option); + +impl ForeignHost for InstalledHost { + fn installed(&self, _app: ForeignApp) -> Option { + self.0 + } +} + +fn fixture(home: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/dbeaver") + .join(home) +} + +fn importer_with(platform: ForeignPlatform, home: &Path, host: Arc) -> ForeignImporter { + ForeignImporter::new(ForeignApp::DBeaver, ForeignAppDirs::conventional(platform, home), host) +} + +fn importer(platform: ForeignPlatform, home: &Path) -> ForeignImporter { + importer_with(platform, home, Arc::new(PlainHost)) +} + +fn macos() -> ForeignImporter { + importer(ForeignPlatform::MacOs, &fixture("macos")) +} + +fn collect(importer: &ForeignImporter, request: ImportRequest) -> CollectedImport { + importer + .collect_blocking(&request, &CancellationToken::new()) + .expect("an import") +} + +fn collect_error(importer: &ForeignImporter) -> ForeignImportError { + match importer.collect_blocking(&CONNECTIONS_ONLY, &CancellationToken::new()) { + Ok(_) => panic!("expected the import to fail"), + Err(error) => error, + } +} + +fn connection<'a>(import: &'a CollectedImport, name: &str) -> &'a CollectedConnection { + import + .connections + .iter() + .find(|connection| connection.settings.name == name) + .unwrap_or_else(|| panic!("no connection named {name}")) +} + +fn query<'a>(import: &'a CollectedImport, name: &str) -> &'a CollectedSavedQuery { + import + .saved_queries + .iter() + .find(|query| query.name == name) + .unwrap_or_else(|| panic!("no saved query named {name}")) +} + +fn password(connection: &CollectedConnection) -> Option<&str> { + connection + .credentials + .as_ref()? + .password + .as_ref() + .map(|secret| secret.expose_secret()) +} + +fn copy_tree(from: &Path, to: &Path) { + fs::create_dir_all(to).expect("a folder"); + for entry in fs::read_dir(from).expect("a fixture folder") { + let entry = entry.expect("an entry"); + let target = to.join(entry.file_name()); + if entry.file_type().expect("a file type").is_dir() { + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), &target).expect("a copy"); + } + } +} + +fn set_modified(path: &Path, modified: SystemTime) { + File::options() + .write(true) + .open(path) + .and_then(|file| file.set_modified(modified)) + .expect("a modification time"); +} + +#[test] +fn is_available_when_a_data_sources_file_exists_for_any_edition() { + let home = fixture("macos-empty"); + assert!(importer(ForeignPlatform::MacOs, &home).is_available_blocking()); + // The macOS app looks past a missing bundle for DBeaver, whose data outlives it. + assert!(importer_with(ForeignPlatform::MacOs, &home, Arc::new(InstalledHost(Some(false)))).is_available_blocking()); +} + +#[test] +fn is_unavailable_without_the_app_or_its_data() { + let home = tempfile::tempdir().expect("a temp dir"); + assert!(!importer(ForeignPlatform::MacOs, home.path()).is_available_blocking()); +} + +#[test] +fn is_available_when_the_app_is_installed_without_data() { + let home = tempfile::tempdir().expect("a temp dir"); + let installed = importer_with(ForeignPlatform::MacOs, home.path(), Arc::new(InstalledHost(Some(true)))); + assert!(installed.is_available_blocking()); +} + +#[test] +fn inventory_counts_connections_and_scripts_that_have_text() { + assert_eq!( + macos().inventory_blocking(), + Inventory { + connections: MACOS_CONNECTION_COUNT, + saved_queries: 5, + } + ); +} + +#[test] +fn inventory_is_empty_when_the_data_is_missing() { + let home = tempfile::tempdir().expect("a temp dir"); + assert_eq!( + importer(ForeignPlatform::MacOs, home.path()).inventory_blocking(), + Inventory::default() + ); +} + +#[test] +fn collect_reads_every_connection() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert_eq!(import.app, ForeignApp::DBeaver); + assert_eq!(import.connections.len(), MACOS_CONNECTION_COUNT); +} + +#[test] +fn providers_map_to_tablepro_engines() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let mappings = [ + ("Conn mysql", "MySQL"), + ("Conn postgresql", "PostgreSQL"), + ("Conn sqlite", "SQLite"), + ("Conn sqlserver", "SQL Server"), + ("Conn oracle", "Oracle"), + ("Conn mongodb", "MongoDB"), + ("Conn mongo", "MongoDB"), + ("Conn redis", "Redis"), + ("Conn clickhouse", "ClickHouse"), + ("Conn mariadb", "MariaDB"), + ("Conn tidb", "TiDB"), + ("Conn cassandra", "Cassandra"), + ]; + for (name, engine) in mappings { + assert_eq!(connection(&import, name).settings.engine, engine, "{name}"); + } +} + +#[test] +fn the_tidb_lakehouse_provider_is_not_imported_as_tidb() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert_ne!(connection(&import, "Lake").settings.engine, "TiDB"); +} + +#[test] +fn an_unknown_provider_passes_through() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert_eq!(connection(&import, "Unknown DB").settings.engine, "exoticdb"); +} + +#[test] +fn a_port_reads_from_a_number_or_a_string() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert_eq!(connection(&import, "PG int port").settings.port, 5433); + assert_eq!(connection(&import, "PG string port").settings.port, 5433); +} + +#[test] +fn a_missing_port_takes_the_engine_s_default() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let defaults = [ + ("Conn postgresql", 5432), + ("Conn mysql", 3306), + ("Conn mongodb", 27_017), + ("Conn redis", 6379), + ("Conn sqlserver", 1433), + ("Conn oracle", 1521), + ("Conn clickhouse", 8123), + ("Conn cassandra", 9042), + ]; + for (name, port) in defaults { + assert_eq!(connection(&import, name).settings.port, port, "{name}"); + } +} + +#[test] +fn a_connection_without_name_or_host_takes_its_id_and_what_its_url_names_never_the_url() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let settings = &connection(&import, "no-name-and-host").settings; + assert_eq!(settings.host, "localhost"); + assert_eq!(settings.port, 5432); + assert_eq!(settings.database, "fromurl"); + assert_eq!(settings.username, ""); +} + +#[test] +fn an_ssh_tunnel_with_a_public_key_keeps_its_key_path() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let ssh = connection(&import, "SSH key PG") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.host, "bastion.example.com"); + assert_eq!(ssh.port, Some(2222)); + assert_eq!(ssh.username, "deploy"); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, "~/.ssh/id_rsa"); + assert!(ssh.jump_hosts.is_empty()); +} + +#[test] +fn an_ssh_tunnel_with_a_host_is_on_unless_disabled_and_a_bare_key_name_is_in_the_ssh_folder() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let ssh = connection(&import, "SSH bare key PG") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.private_key_path, "~/.ssh/id_ed25519"); + assert_eq!(ssh.port, None); + assert!(connection(&import, "SSH disabled PG").settings.ssh.is_none()); +} + +#[test] +fn an_ssh_tunnel_with_the_agent_has_no_key_path() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let ssh = connection(&import, "SSH agent PG") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.auth, ForeignSshAuth::Agent); + assert_eq!(ssh.private_key_path, ""); +} + +#[test] +fn an_ssh_tunnel_with_a_password_has_no_key_path() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let ssh = connection(&import, "SSH password PG") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.auth, ForeignSshAuth::Password); + assert_eq!(ssh.private_key_path, ""); +} + +#[test] +fn an_ssh_port_reads_from_a_string() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let ssh = connection(&import, "SSH string port") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.port, Some(2222)); +} + +#[test] +fn no_ssh_tunnel_without_its_handler() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert!(connection(&import, "Local PG").settings.ssh.is_none()); +} + +#[test] +fn folders_become_group_paths_named_by_their_description() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert_eq!(connection(&import, "Prod PG").group_path, ["Production Servers"]); + assert_eq!(connection(&import, "Dev PG").group_path, ["Development Servers"]); + assert!(connection(&import, "Local PG").group_path.is_empty()); +} + +#[test] +fn a_folder_without_a_description_is_named_by_its_path_component() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert_eq!(connection(&import, "Team PG").group_path, ["team", "backend"]); +} + +#[test] +fn passwords_decrypt_from_the_credentials_file() { + let import = collect(&macos(), WITH_PASSWORDS); + assert_eq!(import.passwords, PasswordOutcome::Read); + assert_eq!(password(connection(&import, "PG with password")), Some("s3cr3t_p4ss")); + let tunnel = connection(&import, "SSH password PG") + .credentials + .as_ref() + .expect("credentials"); + assert_eq!( + tunnel.ssh_password.as_ref().map(|secret| secret.expose_secret()), + Some("tunnel-pass") + ); + assert!(tunnel.password.is_none()); +} + +#[test] +fn without_passwords_no_credentials_are_kept() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert_eq!(import.passwords, PasswordOutcome::NotRequested); + assert!( + import + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); +} + +#[test] +fn the_username_comes_from_the_credentials_file_even_without_passwords() { + for request in [WITH_PASSWORDS, CONNECTIONS_ONLY] { + let import = collect(&macos(), request); + assert_eq!(connection(&import, "User from credentials").settings.username, "sameer"); + } +} + +#[test] +fn the_username_falls_back_to_the_configuration() { + let import = collect(&macos(), WITH_PASSWORDS); + assert_eq!( + connection(&import, "User from configuration").settings.username, + "configuser" + ); +} + +#[test] +fn the_credentials_username_wins_over_the_configuration() { + let import = collect(&macos(), WITH_PASSWORDS); + assert_eq!(connection(&import, "User in both").settings.username, "creduser"); +} + +#[test] +fn an_empty_credentials_username_falls_back_to_the_configuration() { + let import = collect(&macos(), WITH_PASSWORDS); + assert_eq!( + connection(&import, "User empty in credentials").settings.username, + "configuser" + ); +} + +#[test] +fn invalid_json_is_a_parse_error() { + let error = collect_error(&importer(ForeignPlatform::MacOs, &fixture("macos-invalid"))); + assert!(matches!(error, ForeignImportError::Parse { .. }), "{error:?}"); +} + +#[test] +fn no_connections_is_an_error() { + let error = collect_error(&importer(ForeignPlatform::MacOs, &fixture("macos-empty"))); + assert!(matches!(error, ForeignImportError::NothingFound), "{error:?}"); +} + +#[test] +fn a_file_without_connections_is_unsupported() { + let error = collect_error(&importer(ForeignPlatform::MacOs, &fixture("macos-no-connections"))); + assert!( + matches!(error, ForeignImportError::UnsupportedFormat { .. }), + "{error:?}" + ); +} + +#[test] +fn a_missing_data_folder_is_file_not_found() { + let home = tempfile::tempdir().expect("a temp dir"); + let error = collect_error(&importer(ForeignPlatform::MacOs, home.path())); + assert!( + matches!( + error, + ForeignImportError::FileNotFound { + app: ForeignApp::DBeaver + } + ), + "{error:?}" + ); +} + +#[test] +fn a_cancelled_import_stops() { + let cancel = CancellationToken::new(); + cancel.cancel(); + let outcome = macos().collect_blocking(&WITH_SAVED_QUERIES, &cancel); + assert!(matches!(outcome, Err(ForeignImportError::Cancelled))); +} + +#[test] +fn colours_map_from_rgb() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let colours = [ + ("Red", Some(ForeignColor::Red)), + ("Orange", Some(ForeignColor::Orange)), + ("Yellow", Some(ForeignColor::Yellow)), + ("Green", Some(ForeignColor::Green)), + ("Blue", Some(ForeignColor::Blue)), + ("Purple", Some(ForeignColor::Purple)), + ("Grey", None), + ("No Color", None), + ]; + for (name, colour) in colours { + assert_eq!(connection(&import, name).settings.color, colour, "{name}"); + } +} + +#[test] +fn connections_are_keyed_by_their_data_source_id_and_sorted_by_name_then_id() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let names: Vec<&str> = import.connections.iter().map(|c| c.settings.name.as_str()).collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted); + let twins: Vec<&str> = import + .connections + .iter() + .filter(|c| c.settings.name == "Twin") + .map(|c| c.reference.as_str()) + .collect(); + assert_eq!(twins, ["twin-a", "twin-b"]); + assert_eq!(connection(&import, "PG with password").reference, "pg-1"); +} + +#[test] +fn ssl_modes_map_and_an_empty_mode_is_preferred() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let modes = [ + ("SSL Require", ForeignSslMode::Required), + ("SSL Verify CA", ForeignSslMode::VerifyCa), + ("SSL Verify Full", ForeignSslMode::VerifyIdentity), + ("SSL Default", ForeignSslMode::Preferred), + ]; + for (name, mode) in modes { + let ssl = connection(&import, name).settings.ssl.as_ref().expect("SSL"); + assert_eq!(ssl.mode, mode, "{name}"); + } +} + +#[test] +fn ssl_certificate_paths_are_kept() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let ssl = connection(&import, "SSL Certs").settings.ssl.as_ref().expect("SSL"); + assert_eq!(ssl.ca_certificate_path.as_deref(), Some("/path/to/ca.pem")); + assert_eq!(ssl.client_certificate_path.as_deref(), Some("/path/to/cert.pem")); + assert_eq!(ssl.client_key_path.as_deref(), Some("/path/to/key.pem")); +} + +#[test] +fn no_ssl_when_the_handler_is_missing_or_disabled() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert!(connection(&import, "Local PG").settings.ssl.is_none()); + assert!(connection(&import, "SSL Disabled").settings.ssl.is_none()); +} + +#[test] +fn a_script_is_bound_to_the_data_source_dbeaver_opens_it_on() { + let import = collect(&macos(), WITH_SAVED_QUERIES); + let daily = query(&import, "Daily users"); + assert_eq!(daily.content, SavedQueryContent::Text("select ${days};".to_owned())); + assert_eq!(daily.connection.as_deref(), Some("pg-1")); + assert_eq!(daily.folder_path, ["Reports"]); + assert!(daily.suggested); +} + +#[test] +fn an_unbound_script_is_global_under_the_dbeaver_folder() { + let import = collect(&macos(), WITH_SAVED_QUERIES); + let unbound = query(&import, "Unbound"); + assert_eq!(unbound.connection, None); + assert_eq!(unbound.folder_path, ["DBeaver"]); + assert!(unbound.suggested); +} + +#[test] +fn a_script_bound_to_an_unknown_data_source_is_global_and_unchecked() { + let import = collect(&macos(), WITH_SAVED_QUERIES); + let elsewhere = query(&import, "Elsewhere"); + assert_eq!(elsewhere.connection, None); + assert_eq!(elsewhere.folder_path, ["DBeaver"]); + assert!(!elsewhere.suggested); +} + +#[test] +fn default_named_scripts_start_unchecked_and_empty_files_are_skipped() { + let import = collect(&macos(), WITH_SAVED_QUERIES); + let names: BTreeSet<&str> = import.saved_queries.iter().map(|query| query.name.as_str()).collect(); + assert_eq!( + names, + BTreeSet::from(["Script", "Script-3", "Daily users", "Unbound", "Elsewhere"]) + ); + let script = query(&import, "Script"); + assert_eq!(script.connection.as_deref(), Some("pg-1")); + assert!(!script.suggested); + assert!(!query(&import, "Script-3").suggested); +} + +#[test] +fn a_script_over_the_sync_limit_is_listed_as_oversized() { + let home = tempfile::tempdir().expect("a temp dir"); + copy_tree(&fixture("macos"), home.path()); + let scripts = home.path().join("Library/DBeaverData/workspace6/General/Scripts"); + fs::write(scripts.join("Big.sql"), "-".repeat(1_048_576)).expect("a script"); + + let import = collect(&importer(ForeignPlatform::MacOs, home.path()), WITH_SAVED_QUERIES); + let big = query(&import, "Big"); + assert_eq!(big.content, SavedQueryContent::Oversized { byte_count: 1_048_576 }); + assert_eq!(big.connection, None); + assert_eq!(big.folder_path, ["DBeaver"]); + assert!(!big.suggested); +} + +#[test] +fn no_scripts_are_read_unless_saved_queries_are_requested() { + let import = collect(&macos(), CONNECTIONS_ONLY); + assert!(import.saved_queries.is_empty()); +} + +#[test] +fn linux_reads_every_data_sources_file_with_its_own_credentials_file() { + let linux = importer(ForeignPlatform::Linux, &fixture("linux")); + assert_eq!(linux.inventory_blocking().connections, 2); + + let import = collect(&linux, WITH_PASSWORDS); + let names: Vec<&str> = import.connections.iter().map(|c| c.settings.name.as_str()).collect(); + assert_eq!(names, ["From second file", "Linux native"]); + let native = connection(&import, "Linux native"); + assert_eq!(native.settings.username, "native-user"); + assert_eq!(password(native), Some("native-pass")); + let second = connection(&import, "From second file"); + assert_eq!(second.settings.username, "reporter"); + assert_eq!(password(second), Some("sub-pass")); + // The folder is defined in the main file, which DBeaver shares across files. + assert_eq!(second.group_path, ["Shared Servers"]); +} + +#[test] +fn linux_finds_the_flatpak_the_snap_the_old_folder_name_and_a_custom_data_folder() { + let homes = [ + ("linux-flatpak", "Flatpak PG"), + ("linux-snap", "Snap PG"), + ("linux-hidden-folder", "Old install PG"), + ]; + for (home, name) in homes { + let import = collect(&importer(ForeignPlatform::Linux, &fixture(home)), CONNECTIONS_ONLY); + assert_eq!(import.connections.len(), 1, "{home}"); + assert_eq!(import.connections[0].settings.name, name, "{home}"); + } + + let home = fixture("linux-xdg"); + let mut dirs = ForeignAppDirs::conventional(ForeignPlatform::Linux, &home); + dirs.data = home.join("xdg-data"); + let custom = ForeignImporter::new(ForeignApp::DBeaver, dirs, Arc::new(PlainHost)); + assert_eq!( + collect(&custom, CONNECTIONS_ONLY).connections[0].settings.name, + "Custom data PG" + ); +} + +#[test] +fn linux_reads_the_root_whose_data_file_changed_last() { + let home = tempfile::tempdir().expect("a temp dir"); + copy_tree(&fixture("linux"), home.path()); + copy_tree(&fixture("linux-flatpak"), home.path()); + let native = home.path().join(".local/share/DBeaverData/workspace6/General/.dbeaver"); + let flatpak = home + .path() + .join(".var/app/io.dbeaver.DBeaverCommunity/data/DBeaverData/workspace6/General/.dbeaver"); + let earlier = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); + let later = earlier + Duration::from_secs(60); + for file in ["data-sources.json", "data-sources-2.json", "data-sources-broken.json"] { + set_modified(&native.join(file), earlier); + } + set_modified(&flatpak.join("data-sources.json"), later); + let linux = importer(ForeignPlatform::Linux, home.path()); + assert_eq!( + collect(&linux, CONNECTIONS_ONLY).connections[0].settings.name, + "Flatpak PG" + ); + + set_modified(&native.join("data-sources-2.json"), later + Duration::from_secs(60)); + let names: Vec = collect(&linux, CONNECTIONS_ONLY) + .connections + .into_iter() + .map(|c| c.settings.name) + .collect(); + assert_eq!(names, ["From second file", "Linux native"]); +} + +#[test] +fn windows_reads_the_roaming_app_data_folder_with_the_same_key() { + let import = collect(&importer(ForeignPlatform::Windows, &fixture("windows")), WITH_PASSWORDS); + let windows = connection(&import, "Windows PG"); + assert_eq!(windows.settings.username, "winuser"); + assert_eq!(password(windows), Some("windows-pass")); +} + +#[test] +fn each_platform_reads_only_its_own_location() { + assert!(!importer(ForeignPlatform::Linux, &fixture("macos")).is_available_blocking()); + assert!(!importer(ForeignPlatform::MacOs, &fixture("linux")).is_available_blocking()); + assert!(!importer(ForeignPlatform::Windows, &fixture("linux")).is_available_blocking()); +} diff --git a/core/crates/foreign-import/tests/fixtures/beekeeper/.key b/core/crates/foreign-import/tests/fixtures/beekeeper/.key new file mode 100644 index 0000000000..301736f640 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/beekeeper/.key @@ -0,0 +1 @@ +763fb3b63e887e656e553208e5c47f01c1d6d77fcfe320a9db2b6f0a424ea506a3719f7cd947d78b95119401466884fdyczOKlWsfHND89V1/du1i5rEAe6lUNNogMMBmScdbl3H8xynNo5e9OVoP9+kj8XyJwa3BYJesRtwTiZAZlI75HPxw/ekloOk7BuK9inZ855LVioF9R+eB9aaBIPuyHEM diff --git a/core/crates/foreign-import/tests/fixtures/beekeeper/connections.sql b/core/crates/foreign-import/tests/fixtures/beekeeper/connections.sql new file mode 100644 index 0000000000..5e17507d7b --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/beekeeper/connections.sql @@ -0,0 +1,15 @@ +CREATE TABLE saved_connection ( + id INTEGER PRIMARY KEY, name TEXT, connectionType TEXT, host TEXT, port INTEGER, username TEXT, + defaultDatabase TEXT, password TEXT, ssl INTEGER DEFAULT 0, sslCaFile TEXT, sslCertFile TEXT, + sslKeyFile TEXT, sslRejectUnauthorized INTEGER DEFAULT 0, trustServerCertificate INTEGER DEFAULT 0, + sshEnabled INTEGER DEFAULT 0, sshHost TEXT, sshPort INTEGER, sshUsername TEXT, sshMode TEXT, + sshKeyfile TEXT, sshKeyfilePassword TEXT, sshPassword TEXT, sshBastionHost TEXT, + sshBastionHostPort INTEGER, sshBastionUsername TEXT, sshBastionMode TEXT, sshBastionKeyfile TEXT, + labelColor TEXT, connectionFolderId INTEGER, workspaceId INTEGER DEFAULT -1 +); +CREATE TABLE connection_folder (id INTEGER PRIMARY KEY, name TEXT); +INSERT INTO connection_folder (id, name) VALUES (1, 'Production'); +INSERT INTO saved_connection (id, name, connectionType, host, port, username, defaultDatabase, connectionFolderId) + VALUES (1, 'Shop', 'postgresql', 'db.example.com', 5432, 'app', 'shop', 1); +INSERT INTO saved_connection (id, name, connectionType, host, port, workspaceId) + VALUES (2, 'Cloud only', 'mysql', 'cloud.example.com', 3306, 7); diff --git a/core/crates/foreign-import/tests/fixtures/beekeeper/more_connections.sql b/core/crates/foreign-import/tests/fixtures/beekeeper/more_connections.sql new file mode 100644 index 0000000000..d20d5c1ce8 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/beekeeper/more_connections.sql @@ -0,0 +1,26 @@ +-- Passwords are simple-encryptor values under the key that .key holds. +INSERT INTO connection_folder (id, name) VALUES (2, ''); +UPDATE saved_connection SET password = '68d600e7fdde9494299f2919765dee8a00f045edfb8b29df18e302ce97f5709d7df6652278ff5ffba98125b9a15ab670YpZN18cOKT8hHqngs6DlTQ==' WHERE id = 1; +INSERT INTO saved_connection ( + id, name, connectionType, host, port, username, password, ssl, sslCaFile, sslCertFile, sslKeyFile, + sslRejectUnauthorized, sshEnabled, sshHost, sshPort, sshUsername, sshMode, sshKeyfile, sshKeyfilePassword, + sshPassword, sshBastionHost, sshBastionHostPort, sshBastionUsername, sshBastionMode, labelColor, + connectionFolderId +) VALUES ( + 3, 'Tunnelled', 'MySQL', 'db.internal', 3307, 'root', + '828d9d527b455f35508be018f996b52194fdb77b58f11aedbcf82ba5586d86a88fbe105f2d0a460fbe3ec3521697e158bqAob2K0D5qYls/Vpu2TpQ==', + 1, '/certs/ca.pem', '/certs/client.pem', '/certs/client.key', 1, 1, 'bastion.example.com', 2222, 'deploy', + 'keyfile', 'id_ed25519', + '6a24aac78583f2909bc457f7998b95bd183e72c5dcb1aa245d3688a560a70225da69addd820dfacc584e4b4d0f965ecaXefMUQ2vYJrrPc9Eo2hlHA==', + '92bd7a8e4d36592cdb87a9d6a85ac64bfc52127d1f05163170f66004e94a6fcd10ac9ed22202f668b8a9b3e455b5cc44ZHFMLWVbSJvB3S0fwTburQ==', + 'jump.example.com', 22, 'hop', 'agent', 'grey', 2 +); +INSERT INTO saved_connection ( + id, name, connectionType, host, port, password, ssl, sslRejectUnauthorized, trustServerCertificate, sshEnabled, + sshHost, sshMode, sshBastionHost, labelColor +) VALUES (4, '', 'sqlserver', '', NULL, '', 1, 1, 1, 1, 'gateway', 'userpass', '', 'Teal'); +INSERT INTO saved_connection (id, name, connectionType, host, port, ssl) VALUES (5, 'Lake', 'acmedb', 'lake', 8000, 1); +INSERT INTO saved_connection (id, name, connectionType, host) VALUES (6, 'No type', NULL, 'nowhere'); +INSERT INTO saved_connection (id, name, connectionType, host) VALUES (7, 'Empty type', '', 'nowhere'); +ALTER TABLE favorite_query ADD COLUMN workspaceId INTEGER DEFAULT -1; +INSERT INTO favorite_query (id, title, text, workspaceId) VALUES (6, 'Cloud report', 'select 2;', 7); diff --git a/core/crates/foreign-import/tests/fixtures/beekeeper/queries.sql b/core/crates/foreign-import/tests/fixtures/beekeeper/queries.sql new file mode 100644 index 0000000000..830517f711 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/beekeeper/queries.sql @@ -0,0 +1,11 @@ +CREATE TABLE query_folder (id INTEGER PRIMARY KEY, name TEXT, parentId INTEGER); +CREATE TABLE favorite_query ( + id INTEGER PRIMARY KEY, title TEXT, text TEXT, database TEXT, connectionHash TEXT, queryFolderId INTEGER +); +INSERT INTO query_folder (id, name, parentId) VALUES (1, 'Reports', NULL), (2, 'Daily', 1); +INSERT INTO favorite_query (id, title, text, queryFolderId) VALUES + (1, 'Demo Query', 'select 1;', NULL), + (2, 'Top customers', 'select * from customers where region = ${region};', NULL), + (3, 'Daily active users', 'select count(*) from sessions;', 2), + (4, 'Blank', ' ', NULL), + (5, 'Huge', replace(hex(zeroblob(900001)), '00', 'x'), 1); diff --git a/core/crates/foreign-import/tests/fixtures/beekeeper/queries_without_folders.sql b/core/crates/foreign-import/tests/fixtures/beekeeper/queries_without_folders.sql new file mode 100644 index 0000000000..6c1d07f2e4 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/beekeeper/queries_without_folders.sql @@ -0,0 +1,2 @@ +CREATE TABLE favorite_query (id INTEGER PRIMARY KEY, title TEXT, text TEXT, database TEXT, connectionHash TEXT); +INSERT INTO favorite_query (id, title, text) VALUES (1, 'Orders', 'select * from orders;'); diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/.gitignore b/core/crates/foreign-import/tests/fixtures/datagrip/.gitignore new file mode 100644 index 0000000000..8b73918f6f --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/.gitignore @@ -0,0 +1,2 @@ +# DataGrip keeps project data sources in .idea, which the repository ignores. +!.idea/ diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/c.kdbx b/core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/c.kdbx new file mode 100644 index 0000000000000000000000000000000000000000..df7ae6f17afc0515de78337bbb3f551b4bcfe7be GIT binary patch literal 939 zcmV;c162G2*`k_f`%AR}00RI61ONa4000Ca0BBnKY8|ld`WF$O>eZ#c43Q;7Xl7B! z%-rq^Gm)3$@&zCOw!E72nN&%8>V?ti`9nJARg2+Z|0HFxK}l6U&oA)|1_%H=00000 z000075C8@+-t<<3?vz14xoxP`E`SIi04bi*>guMk;~U==@-7=#iyP^9;!_ScNPByZ zu=fzJDhVI}%s<}kMa=}sJV2nzA`T`&$(HQbc)c1%!NrITufVY^3IqTH000001ON>R z4GP@vve~Ziml+NZR?TM4B9NV+63%gOPRyK_Pt61fsvE_)|B11!dVOO8XSP^nilbe7 zr#XUneg~qQ9!4Z6m6K04ZtaX>d%|cbuL9PDZDa$O;#fvIgOY_mw`2)!PF?jzaZj?p z?n1NMO7Fu55qF?sMvI-#J(Cf1@A7t9D-A+Ln1{!V;hzn57+)ZNJkXU@*Zi5PY3=+> zgT~Dj2x3Na)rrlj3D0a3MCgCWc4Ww-=xFR7{hRpT2rqH;=K%)C;K}@epHb%jS+SB(MhE+o(!u(aV_SV6lmI^&8yP9qhg`XhgeP-<&m6FGjt*B-jE#K&OR3q@FO8tBgZLQpuV@Qd%TlBu zCn1<|JX|f`1#j4pE8!z|#he^R$4sPyoWOhQUTv8j20>Xp>Y}Q}+M-138lG&yLUZn} zf+Y7tLNOO-)Uw(cE87sgZ#RAC`U2yoHur2j>2- + + + + appuser + + true + SSH1 + + + + + true + SSH3 + + + + + false + + + + + true + SSH2 + + + + postgres + + + + $USER_HOME$/certs/ca.pem + $USER_HOME$/certs/client.crt + $USER_HOME$/certs/client.key + true + VERIFY_FULL + + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.xml new file mode 100644 index 0000000000..aac2d82ca0 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.xml @@ -0,0 +1,62 @@ + + + + + mysql.8 + jdbc:mysql://db.example.com:3307/shop + root + + + postgresql + jdbc:postgresql://localhost/app + + + sqlserver.ms + jdbc:sqlserver://h:1433;databaseName=c + + + oracle + jdbc:oracle:thin:@h:1521:ORCL + + + sqlite.xerial + jdbc:sqlite:/Users/me/app.db + + + redshift + jdbc:redshift://cluster.example.com/dev + + + mysql.8 + jdbc:mysql://h:3306/a + + + mysql.8 + jdbc:mysql://h:3306/a + + + mysql.8 + jdbc:mysql://h:3306/a + + + mysql.8 + jdbc:mysql://h:3306/a + + + postgresql + jdbc:postgresql://h:5432/db + + + postgresql + jdbc:postgresql://h:5432/a + + + mysql.8 + jdbc:mysql://h:3306/a + + + mysql.8 + jdbc:mysql://h:3306/a + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/sshConfigs.xml b/core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/sshConfigs.xml new file mode 100644 index 0000000000..cd17325f9f --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/connections/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/sshConfigs.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_legacy.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_legacy.sql new file mode 100644 index 0000000000..5b5fbf366e --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_legacy.sql @@ -0,0 +1 @@ +select 'only old'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_report.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_report.sql new file mode 100644 index 0000000000..b247539ff8 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_report.sql @@ -0,0 +1 @@ +select 'old'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/options/dataSources.xml new file mode 100644 index 0000000000..a7f9a65172 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2025.3/options/dataSources.xml @@ -0,0 +1,9 @@ + + + + + sqlite.xerial + jdbc:sqlite:/tmp/dg_sample_shop.sqlite + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/.history/db/11111111-1111-4111-8111-111111111111.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/.history/db/11111111-1111-4111-8111-111111111111.sql new file mode 100644 index 0000000000..432214702e --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/.history/db/11111111-1111-4111-8111-111111111111.sql @@ -0,0 +1 @@ +select 'history'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/.hidden_console.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/.hidden_console.sql new file mode 100644 index 0000000000..c4b2ae2d67 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/.hidden_console.sql @@ -0,0 +1 @@ +select 'hidden'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/Console_7.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/Console_7.sql new file mode 100644 index 0000000000..2c0fba1e01 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/Console_7.sql @@ -0,0 +1 @@ +select 7; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console.sql new file mode 100644 index 0000000000..9e13a3eff4 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console.sql @@ -0,0 +1 @@ +select 1; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console_1.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console_1.sql new file mode 100644 index 0000000000..a14050d4b7 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console_1.sql @@ -0,0 +1 @@ +select 2; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console_3.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console_3.sql new file mode 100644 index 0000000000..1a4baf536d --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console_3.sql @@ -0,0 +1 @@ + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_report.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_report.sql new file mode 100644 index 0000000000..de4db92f96 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_report.sql @@ -0,0 +1 @@ +select * from dg_sample_items; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/notes.txt b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/notes.txt new file mode 100644 index 0000000000..b0438ff477 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/notes.txt @@ -0,0 +1 @@ +not sql \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/reports/dg_sample_monthly.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/reports/dg_sample_monthly.sql new file mode 100644 index 0000000000..eac886c290 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/reports/dg_sample_monthly.sql @@ -0,0 +1 @@ +select count(*) from dg_sample_items; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/script.js b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/script.js new file mode 100644 index 0000000000..2e245cf5d5 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/script.js @@ -0,0 +1 @@ +db.items.find() \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/22222222-2222-4222-8222-222222222222/dg_sample_orphan.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/22222222-2222-4222-8222-222222222222/dg_sample_orphan.sql new file mode 100644 index 0000000000..b992b45af6 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/22222222-2222-4222-8222-222222222222/dg_sample_orphan.sql @@ -0,0 +1 @@ +select 9; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/33333333-3333-4333-8333-333333333333/console.js b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/33333333-3333-4333-8333-333333333333/console.js new file mode 100644 index 0000000000..3d5e74656c --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/33333333-3333-4333-8333-333333333333/console.js @@ -0,0 +1 @@ +db.dg_sample_items.find() \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/44444444-4444-4444-8444-444444444444/dg_unmapped_query.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/44444444-4444-4444-8444-444444444444/dg_unmapped_query.sql new file mode 100644 index 0000000000..89b99dbabb --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/44444444-4444-4444-8444-444444444444/dg_unmapped_query.sql @@ -0,0 +1 @@ +select 4; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml new file mode 100644 index 0000000000..9bf7dad324 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml @@ -0,0 +1,14 @@ + + + + + sqlite.xerial + jdbc:sqlite:/tmp/dg_sample_shop.sqlite + + + mongo + mongodb://localhost:27017/dg_sample + + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/scratches/scratch.sql b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/scratches/scratch.sql new file mode 100644 index 0000000000..75d9fa187d --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/consoles/macos/Library/Application Support/JetBrains/DataGrip2026.2/scratches/scratch.sql @@ -0,0 +1 @@ +select 'global'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/escape/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/escape/dg_sample_outside.sql b/core/crates/foreign-import/tests/fixtures/datagrip/escape/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/escape/dg_sample_outside.sql new file mode 100644 index 0000000000..188bc2dff4 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/escape/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/escape/dg_sample_outside.sql @@ -0,0 +1 @@ +select 'outside'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/escape/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/escape/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml new file mode 100644 index 0000000000..035d9d7aef --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/escape/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml @@ -0,0 +1,9 @@ + + + + + sqlite.xerial + jdbc:sqlite:/tmp/dg_sample_escape.sqlite + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/legacy-key/linux/.config/JetBrains/DataGrip2023.1/c.kdbx b/core/crates/foreign-import/tests/fixtures/datagrip/legacy-key/linux/.config/JetBrains/DataGrip2023.1/c.kdbx new file mode 100644 index 0000000000000000000000000000000000000000..43fe4d372254cd95316475fc25c4600b5610f809 GIT binary patch literal 571 zcmV-B0>u3T*`k_f`%AR}00RI61ONd5000Ca0BBnKY8|ld`WF$O>eZ#c43Q;7Xl7B! z%-rq^Gm)3$@&zCOw!E72nN&%8>V?ti`9nJARg2+Z|0HFxK}l6U&oA)|1_%H=00000 z000075C8@+-t<<3?vz14xoxP`E`SIi04bi*>guMk;~U==@-7=#iyP^9;!_ScNPByZ zu=fzJDhVI}%s<}kMa=}sJV2nzA`T`&$(HQbc)c1%!NrITufVY^3IqTG000001ON>R z4GIS+m9?#K@zjlF7`EBQ+CLhGmNRZ|RkL%?j54qy3~Vw+;dB??KlXL-*m}r^w6#(B zR(x7P^$6R0{^P=9rnx&b08p|qat!E=jC(XgsHx+#L_xy^9%RaM-QNYd7S^E$7Q)L4 ziFDfUl$g!dlKDoF3ugeVoLa~aFE$o35OL0B%M|%eA8#|B&M|123X)ay zP(rM;48H&lPdh0eEQILmYZz@7sss?IPsNV9T+=|cOG4Wc + + + + mysql.8 + jdbc:mysql://legacy.example.com:3306/app + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/legacy-key/linux/.config/JetBrains/DataGrip2023.1/pdb.pwd b/core/crates/foreign-import/tests/fixtures/datagrip/legacy-key/linux/.config/JetBrains/DataGrip2023.1/pdb.pwd new file mode 100644 index 0000000000000000000000000000000000000000..574d8731db826c9c9c55ecbe04686a756f351582 GIT binary patch literal 100 zcmV-q0Gs~+000no(ReV%-Uifw!O-R=$IJ^IIlb`QuG2Uoaj#(Xw2JqPCeZ#c43Q;7Xl7B! z%-rq^Gm)3$@&zCOw!E72nN&%8>V?ti`9nJARg2+Z|0HFxK}l6U&oA)|1_%H=00000 z000075C8@+-t<<3?vz14xoxP`E`SIi04bi*>guMk;~U==@-7=#iyP^9;!_ScNPByZ zu=fzJDhVI}%s<}kMa=}sJV2nzA`T`&$(HQbc)c1%!NrITufVY^3IqTH000001ON>R z4GPl?CvFS1)Z?aDfS#FM6A|)oRIpmDy)uwX<<6!%xlap3+Ry;ih@gF=vyuF|VeqxH zS-m=(^>oGW#@@QHRhmCFLNv14%@$nN&v0L^H-a=6@ED-$bEBL!kq+gI<1#G1!b3t{ zT-j+BW4x@TJLHXwImdw@R(5PA)(Xls;>jLA{aGPi4@#8-q$lLXycE7tC6iIX zD9G+q!Ras?0aRI{9eoUr&WRm+AsY|Qy%zJ)F`u+u^7zzy((JXbx)As?J)86gs9)Xk zL$VTY=QQE22TAhe0vd1c@9Jt~8P;ouW}C_ATP--<<)SO8cp|{r&iwsz)`PL{poKTI z4FV&%HN&Kcy~n~Im_r48LzNru#|JHYVSI+0FS_g+BZzJ}hauZ8j2v^SRrg8(3M0)` zwaH&5>%`EQaN!20Iy$951cu2~0Y9b&HIGCFx9@o5(UC*@{H`=J5&s$$%(KJox#?NC z%lR5(}e4p<+P>w)6v$T~h#bZ!NJClBC;Hdxh$ x3Yao}b#w`5f + + + + sqlserver.ms + jdbc:sqlserver://sql.example.com:1433;databaseName=Sales + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/options/dataSources.xml new file mode 100644 index 0000000000..a5c779e912 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/options/dataSources.xml @@ -0,0 +1,13 @@ + + + + + postgresql + jdbc:postgresql://native.example.com:5432/app + + + mysql.8 + jdbc:mysql://old.example.com:3306/shop + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/options/recentProjects.xml b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/options/recentProjects.xml new file mode 100644 index 0000000000..497a1d0c47 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/options/recentProjects.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/projects/default/.idea/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/projects/default/.idea/dataSources.xml new file mode 100644 index 0000000000..661a3eb348 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/DataGrip2025.1/projects/default/.idea/dataSources.xml @@ -0,0 +1,9 @@ + + + + + sqlite.xerial + jdbc:sqlite:/home/me/default.db + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/IntelliJIdea2025.3/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/IntelliJIdea2025.3/options/dataSources.xml new file mode 100644 index 0000000000..a2cef1e39c --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.config/JetBrains/IntelliJIdea2025.3/options/dataSources.xml @@ -0,0 +1,9 @@ + + + + + mysql.8 + jdbc:mysql://idea.example.com/x + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.var/app/com.jetbrains.DataGrip/config/JetBrains/DataGrip2025.2/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.var/app/com.jetbrains.DataGrip/config/JetBrains/DataGrip2025.2/options/dataSources.xml new file mode 100644 index 0000000000..bdd0562a93 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/.var/app/com.jetbrains.DataGrip/config/JetBrains/DataGrip2025.2/options/dataSources.xml @@ -0,0 +1,13 @@ + + + + + mysql.8 + jdbc:mysql://new.example.com:3306/shop + + + clickhouse + jdbc:clickhouse://ch.example.com:8123/metrics + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/DataGripProjects/shop/.idea/dataSources.local.xml b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/DataGripProjects/shop/.idea/dataSources.local.xml new file mode 100644 index 0000000000..71dcda77ac --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/DataGripProjects/shop/.idea/dataSources.local.xml @@ -0,0 +1,8 @@ + + + + + shop_user + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/DataGripProjects/shop/.idea/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/DataGripProjects/shop/.idea/dataSources.xml new file mode 100644 index 0000000000..366ee3a0b1 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/DataGripProjects/shop/.idea/dataSources.xml @@ -0,0 +1,9 @@ + + + + + mariadb + jdbc:mariadb://project.example.com:3307/shop + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/xdg-config/JetBrains/DataGrip2025.2/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/xdg-config/JetBrains/DataGrip2025.2/options/dataSources.xml new file mode 100644 index 0000000000..3ada047153 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/roots/linux/xdg-config/JetBrains/DataGrip2025.2/options/dataSources.xml @@ -0,0 +1,9 @@ + + + + + mysql.8 + jdbc:mysql://xdg.example.com:3306/shop + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console.sql b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console.sql new file mode 100644 index 0000000000..7782f40748 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/console.sql @@ -0,0 +1 @@ +select 3; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_q.sql b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_q.sql new file mode 100644 index 0000000000..9e13a3eff4 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_q.sql @@ -0,0 +1 @@ +select 1; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_q_2.sql b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_q_2.sql new file mode 100644 index 0000000000..a14050d4b7 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/consoles/db/11111111-1111-4111-8111-111111111111/dg_sample_q_2.sql @@ -0,0 +1 @@ +select 2; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/QueryFileSettings.xml b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/QueryFileSettings.xml new file mode 100644 index 0000000000..5e18b46d48 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/QueryFileSettings.xml @@ -0,0 +1,5 @@ + + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml new file mode 100644 index 0000000000..a7f9a65172 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/scratches-name/macos/Library/Application Support/JetBrains/DataGrip2026.2/options/dataSources.xml @@ -0,0 +1,9 @@ + + + + + sqlite.xerial + jdbc:sqlite:/tmp/dg_sample_shop.sqlite + + + diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux-flatpak/.var/app/io.dbeaver.DBeaverCommunity/data/DBeaverData/workspace6/General/.dbeaver/data-sources.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-flatpak/.var/app/io.dbeaver.DBeaverCommunity/data/DBeaverData/workspace6/General/.dbeaver/data-sources.json new file mode 100644 index 0000000000..5476c0f77f --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-flatpak/.var/app/io.dbeaver.DBeaverCommunity/data/DBeaverData/workspace6/General/.dbeaver/data-sources.json @@ -0,0 +1,13 @@ +{ + "connections": { + "flatpak": { + "provider": "postgresql", + "name": "Flatpak PG", + "configuration": { + "host": "flatpak.example.com", + "port": "5432", + "database": "app" + } + } + } +} diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux-hidden-folder/.local/share/.DBeaverData/workspace6/General/.dbeaver/data-sources.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-hidden-folder/.local/share/.DBeaverData/workspace6/General/.dbeaver/data-sources.json new file mode 100644 index 0000000000..73942d1bed --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-hidden-folder/.local/share/.DBeaverData/workspace6/General/.dbeaver/data-sources.json @@ -0,0 +1,13 @@ +{ + "connections": { + "old-install": { + "provider": "postgresql", + "name": "Old install PG", + "configuration": { + "host": "old-install.example.com", + "port": "5432", + "database": "app" + } + } + } +} diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux-snap/snap/dbeaver-ce/current/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-snap/snap/dbeaver-ce/current/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json new file mode 100644 index 0000000000..62af50e936 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-snap/snap/dbeaver-ce/current/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json @@ -0,0 +1,13 @@ +{ + "connections": { + "snap": { + "provider": "postgresql", + "name": "Snap PG", + "configuration": { + "host": "snap.example.com", + "port": "5432", + "database": "app" + } + } + } +} diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux-xdg/xdg-data/DBeaverData/workspace6/General/.dbeaver/data-sources.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-xdg/xdg-data/DBeaverData/workspace6/General/.dbeaver/data-sources.json new file mode 100644 index 0000000000..9c04286583 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-xdg/xdg-data/DBeaverData/workspace6/General/.dbeaver/data-sources.json @@ -0,0 +1,13 @@ +{ + "connections": { + "xdg": { + "provider": "postgresql", + "name": "Custom data PG", + "configuration": { + "host": "xdg.example.com", + "port": "5432", + "database": "app" + } + } + } +} diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config-2.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config-2.json new file mode 100644 index 0000000000000000000000000000000000000000..51e0c4a0480c136a0440391c72c366d100c3f768 GIT binary patch literal 112 zcmV-$0FVD8p})>VAMk~%DPD<8*HN8JndFkn12a$yb)Hdl`;n0t7u`l+0MN|ubG&l& zX*=vs#b~^&S0sj8>NU=a+-^}kV%^>K=>Kv;uP%GMiR6ZIv#qPeX-0NwTn3gz!zOb* SmJSA0a-&ICUHW_QG~T53am=_EhsQ{VqIF z&(spR73kjU+|1039$gf37#n`vg4twoo?h7%b29Df(L?2}HP+sk@0okt19u+B=F%2q zR_qc4WLf&_>Pub4AW{UzW&lgh2Ch`qQ$G+rgUC#0lWZ8{?uXbD*KbQreY7vbeRC!6 aWJIydQEpZmB2YAH@19$Qfyh(J2wY_e?Q`1z literal 0 HcmV?d00001 diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config.plain.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config.plain.json new file mode 100644 index 0000000000..e523ecdd97 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/credentials-config.plain.json @@ -0,0 +1,14 @@ +{ + "linux-native": { + "#connection": { + "user": "native-user", + "password": "native-pass" + } + }, + "sub-1": { + "#connection": { + "user": "not-this-one", + "password": "not-this-one" + } + } +} diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources-2.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources-2.json new file mode 100644 index 0000000000..63a94a57f0 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources-2.json @@ -0,0 +1,14 @@ +{ + "connections": { + "sub-1": { + "provider": "postgresql", + "name": "From second file", + "folder": "Shared", + "configuration": { + "host": "second.example.com", + "port": "5432", + "database": "reports" + } + } + } +} diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources-broken.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources-broken.json new file mode 100644 index 0000000000..05d9eed41d --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources-broken.json @@ -0,0 +1 @@ +{"connections": { \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json new file mode 100644 index 0000000000..9c38484586 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/linux/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json @@ -0,0 +1,19 @@ +{ + "folders": { + "Shared": { + "description": "Shared Servers" + } + }, + "connections": { + "linux-native": { + "provider": "mysql", + "name": "Linux native", + "configuration": { + "host": "native.example.com", + "port": "3306", + "database": "app", + "user": "root" + } + } + } +} diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/macos-empty/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json b/core/crates/foreign-import/tests/fixtures/dbeaver/macos-empty/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json new file mode 100644 index 0000000000..066708ac6e --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/macos-empty/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json @@ -0,0 +1,3 @@ +{ + "connections": {} +} diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/macos-invalid/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json b/core/crates/foreign-import/tests/fixtures/dbeaver/macos-invalid/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json new file mode 100644 index 0000000000..c525edcfcd --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/macos-invalid/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json @@ -0,0 +1 @@ +not valid json {{{ \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/macos-no-connections/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json b/core/crates/foreign-import/tests/fixtures/dbeaver/macos-no-connections/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json new file mode 100644 index 0000000000..8ef7cbdea2 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/macos-no-connections/Library/DBeaverData/workspace6/General/.dbeaver/data-sources.json @@ -0,0 +1,3 @@ +{ + "folders": {} +} diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/.dbeaver/credentials-config.json b/core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/.dbeaver/credentials-config.json new file mode 100644 index 0000000000000000000000000000000000000000..bfe94e14324ce148ba7fe6244f72b93b6f1edc1e GIT binary patch literal 416 zcmV;R0bl+`1LL?Z_d_Yvk&TiAGK{lM4l-T_$cmJk2h~aFeGq>fb=q_J~6r< zhLg>%R7c)G8=QqtMJ3cM7^cN60uEd83-H8zO1GxZ!i!A&Qx42I$DU(=!oIgM`n{K;=IO$r-%)ts>B z3 + + + + + + + + + + + + diff --git a/core/crates/foreign-import/tests/fixtures/navicat/doctype.ncx b/core/crates/foreign-import/tests/fixtures/navicat/doctype.ncx new file mode 100644 index 0000000000..d8e1632109 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/navicat/doctype.ncx @@ -0,0 +1,7 @@ + + +]> + + + diff --git a/core/crates/foreign-import/tests/fixtures/navicat/empty.ncx b/core/crates/foreign-import/tests/fixtures/navicat/empty.ncx new file mode 100644 index 0000000000..b8aefaeccb --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/navicat/empty.ncx @@ -0,0 +1,3 @@ + + + diff --git a/core/crates/foreign-import/tests/fixtures/navicat/malformed.ncx b/core/crates/foreign-import/tests/fixtures/navicat/malformed.ncx new file mode 100644 index 0000000000..05f161b1bf --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/navicat/malformed.ncx @@ -0,0 +1 @@ +<< + + + + Favorites Root + + Children + + + colorIndex + 0 + database + + host + db.example.com + id + 1 + name + Color 0 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + 1 + database + + host + db.example.com + id + 2 + name + Color 1 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + 2 + database + + host + db.example.com + id + 3 + name + Color 2 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + 3 + database + + host + db.example.com + id + 4 + name + Color 3 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + 4 + database + + host + db.example.com + id + 5 + name + Color 4 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + 5 + database + + host + db.example.com + id + 6 + name + Color 5 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + 6 + database + + host + db.example.com + id + 7 + name + Color 6 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + 7 + database + + host + db.example.com + id + 8 + name + Color 7 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + -1 + database + + host + db.example.com + id + 9 + name + Color -1 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + 99 + database + + host + db.example.com + id + 10 + name + Color 99 + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + IsExpanded + + Name + FAVORITES + + + diff --git a/core/crates/foreign-import/tests/fixtures/sequel_ace/corrupt/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist b/core/crates/foreign-import/tests/fixtures/sequel_ace/corrupt/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist new file mode 100644 index 0000000000..f8d2848e29 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/sequel_ace/corrupt/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist @@ -0,0 +1,4 @@ + + + + Favorites Root diff --git a/core/crates/foreign-import/tests/fixtures/sequel_ace/empty/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist b/core/crates/foreign-import/tests/fixtures/sequel_ace/empty/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist new file mode 100644 index 0000000000..9e6c6840f9 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/sequel_ace/empty/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist @@ -0,0 +1,15 @@ + + + + + Favorites Root + + Children + + IsExpanded + + Name + FAVORITES + + + diff --git a/core/crates/foreign-import/tests/fixtures/sequel_ace/full/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist b/core/crates/foreign-import/tests/fixtures/sequel_ace/full/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist new file mode 100644 index 0000000000..7f028597b9 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/sequel_ace/full/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist @@ -0,0 +1,324 @@ + + + + + Favorites Root + + Children + + + Children + + + colorIndex + 0 + database + shop + host + prod.example.com + id + 1 + name + Prod Main + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + /path/to/ca.pem + sslCertificateFileLocation + /path/to/client-cert.pem + sslKeyFileLocation + /path/to/client-key.pem + type + 0 + useSSL + 1 + user + root + + + colorIndex + 1 + database + shop + host + 10.0.0.5 + id + 2 + name + Prod Replica + port + 3307 + socket + + sshHost + bastion.example.com + sshKeyLocation + ~/.ssh/id_ed25519 + sshKeyLocationEnabled + 1 + sshPort + 2222 + sshUser + deploy + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 2 + useSSL + 0 + user + replica + + + IsExpanded + + Name + Production + + + colorIndex + -1 + database + + host + 127.0.0.1 + id + 42 + name + Local + port + + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + Children + + + Children + + + colorIndex + -1 + database + staging + host + staging.internal + id + staging-eu + name + Staging EU + port + 3306 + socket + + sshHost + bastion.eu.example.com + sshKeyLocation + id_rsa + sshKeyLocationEnabled + 0 + sshPort + 3333 + sshUser + admin + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 2 + useSSL + + user + app + + + IsExpanded + + Name + EU + + + IsExpanded + + Name + Staging + + + Children + + IsExpanded + + Name + Empty Group + + + colorIndex + 7 + database + + host + localhost + id + -5034467137374584238 + name + Socket DB + port + 3306 + socket + /tmp/mysql.sock + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 1 + useSSL + 0 + user + root + + + Children + + + colorIndex + 99 + database + + host + member.example.com + id + 4 + name + Unnamed Member + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 0 + useSSL + 0 + user + root + + + colorIndex + -1 + database + + host + keyed.internal + id + 5 + name + Key By Name + port + 3306 + socket + + sshHost + jump.example.com + sshKeyLocation + id_rsa + sshKeyLocationEnabled + 1 + sshPort + + sshUser + ops + sslCACertFileLocation + + sslCertificateFileLocation + + sslKeyFileLocation + + type + 2 + useSSL + 0 + user + root + + + IsExpanded + + + + IsExpanded + + Name + FAVORITES + + + diff --git a/core/crates/foreign-import/tests/fixtures/sequel_ace/full/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Preferences/com.sequel-ace.sequel-ace.plist b/core/crates/foreign-import/tests/fixtures/sequel_ace/full/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Preferences/com.sequel-ace.sequel-ace.plist new file mode 100644 index 0000000000000000000000000000000000000000..1ffe6488c6ed1e65b519ac7b8cb9928fc0bb94bf GIT binary patch literal 247 zcmYc)$jK}&F)+Bq$i&PNFCZM8pPTAml95^zP?VaMT9lfXoEleHnp#xpmROcwlv$Em z%(;Y>hhOM28#@Ol7k5ZrVs2_ESV2@tVp2&_W_o&RQEX6Za%x_QLVi(7YEf~#fO@d2 zkE?Ttf|i0?kiRcfSRvfQHON&VGeyBpL8aQzs-o69s3`vu8#^ywXh2S4W?m#zm!UOK zP(U!$DJL;6J5)h|OF;oBAS@E + + + + Something Else + + Children + + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/Reports/monthly.sql b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/Reports/monthly.sql new file mode 100644 index 0000000000..84700c0990 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/Reports/monthly.sql @@ -0,0 +1 @@ +SELECT 'monthly'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/Reports/monthly.sql.tag b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/Reports/monthly.sql.tag new file mode 100644 index 0000000000..d3c2c5399a --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/Reports/monthly.sql.tag @@ -0,0 +1 @@ +monthly \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/daily.sql b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/daily.sql new file mode 100644 index 0000000000..c8f9c11bbe --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/queries/daily.sql @@ -0,0 +1 @@ +SELECT 'daily'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/connection_groups.json b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/connection_groups.json new file mode 100644 index 0000000000..4a2e7188e5 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/connection_groups.json @@ -0,0 +1,17 @@ +{ + "objects": [ + { + "id": "lg-1", + "name": "Work", + "group-id": "", + "is-expanded": true + }, + { + "id": "lg-2", + "name": "Databases", + "group-id": "lg-1", + "is-expanded": false + } + ], + "updated_at": 1730600000 +} diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/connections.json b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/connections.json new file mode 100644 index 0000000000..b5db8a492c --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/connections.json @@ -0,0 +1,52 @@ +{ + "objects": [ + { + "id": "lx-1", + "name": "Linux Postgres", + "db-driver": "PostgreSQL", + "db-host": "db.internal", + "db-port": "5433", + "db-user": "app", + "db-name": "shop", + "db-path": "", + "use-ssh-tunnel": 1, + "ssh-host": "bastion", + "ssh-port": "2222", + "ssh-user": "deploy", + "ssh-key-path": "/home/me/.ssh/id_ed25519", + "ssl-mode": 2, + "ssl-key-path-1": "/home/me/tls/client.key", + "tag": 1, + "color": "#d0021b", + "group-id": "lg-2", + "db-use-command-line-password": false + }, + { + "id": "lx-2", + "name": "Linux SQLite", + "db-driver": "SQLite", + "db-host": "", + "db-port": "", + "db-path": "/home/me/data.db", + "use-ssh-tunnel": 0, + "ssh-key-path": "", + "ssl-mode": -1, + "group-id": "" + }, + { + "id": "lx-3", + "name": "Linux Command", + "db-driver": "MySQL", + "db-host": "localhost", + "db-port": "", + "use-ssh-tunnel": 0, + "db-use-command-line-password": true, + "db-command-line-password": "pass show db" + }, + { + "id": "lx-4", + "db-driver": "MySQL" + } + ], + "updated_at": 1730600000 +} diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/settings.json b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/settings.json new file mode 100644 index 0000000000..bbeafc43b4 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/linux/.tableplus/settings/settings.json @@ -0,0 +1,4 @@ +{ + "theme": 2, + "shared-folder-paths": [] +} diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/linux/Shared/team.sql b/core/crates/foreign-import/tests/fixtures/tableplus/linux/Shared/team.sql new file mode 100644 index 0000000000..90f1fdd3ee --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/linux/Shared/team.sql @@ -0,0 +1 @@ +SELECT 'team'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-empty/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos-empty/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist new file mode 100644 index 0000000000..5dd5da85fd --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-empty/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-invalid/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos-invalid/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist new file mode 100644 index 0000000000..dd278014ee --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-invalid/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist @@ -0,0 +1,18 @@ + + + + + + Driver + MySQL + ID + x + + + Driver + MySQL + ID + x + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Application Support/com.tinyapp.TablePlus-setapp/Data/Connections.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Application Support/com.tinyapp.TablePlus-setapp/Data/Connections.plist new file mode 100644 index 0000000000..a18bcafe17 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Application Support/com.tinyapp.TablePlus-setapp/Data/Connections.plist @@ -0,0 +1,28 @@ + + + + + + ConnectionName + Setapp DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + setapp-1 + isOverSSH + + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Application Support/com.tinyapp.TablePlus-setapp/Data/Favorite/setapp.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Application Support/com.tinyapp.TablePlus-setapp/Data/Favorite/setapp.sql new file mode 100644 index 0000000000..e938b341c0 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Application Support/com.tinyapp.TablePlus-setapp/Data/Favorite/setapp.sql @@ -0,0 +1 @@ +SELECT 'setapp'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Preferences/com.tinyapp.TablePlus-setapp.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/Library/Preferences/com.tinyapp.TablePlus-setapp.plist new file mode 100644 index 0000000000000000000000000000000000000000..c2f26cdcc5331eadce7c54223000e45f70ff00f1 GIT binary patch literal 239 zcmYc)$jK}&F)+Bq$i&PNFCgp}9G;n%l3%XiR+N~Vs^FWLnHL?FnOYv4T2hjkmmY1P zV4z@OV4z@TU~rL@Ez&KqEWaqTB(-=EJI^H!PA=|{g2a-HkkZo3l;}GBl7jf+jKreU zlz0IZOJ_qDb7w~czS6vflQ&U|_7c)m)BWFV=GdBZA0|Ns?pb5NuVRicH`FW|t iX))s9Ro1t*^Y9J`jY{jCG?M`g7#Sfn13#38Q3n96*gK{G literal 0 HcmV?d00001 diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/tp_shared/shared.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/tp_shared/shared.sql new file mode 100644 index 0000000000..96d85b8506 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/tp_shared/shared.sql @@ -0,0 +1 @@ +SELECT 8; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/tp_shared/shared.sql.tag b/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/tp_shared/shared.sql.tag new file mode 100644 index 0000000000..82a564ad4b --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-setapp/tp_shared/shared.sql.tag @@ -0,0 +1 @@ +kwshared \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Custom/Favorite/custom.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Custom/Favorite/custom.sql new file mode 100644 index 0000000000..a2dab50068 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Custom/Favorite/custom.sql @@ -0,0 +1 @@ +SELECT 6; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Custom/ignored.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Custom/ignored.sql new file mode 100644 index 0000000000..57bc3ecccc --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Custom/ignored.sql @@ -0,0 +1 @@ +SELECT 7; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist new file mode 100644 index 0000000000..70bea3d831 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist @@ -0,0 +1,28 @@ + + + + + + ConnectionName + Stale + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + c1 + isOverSSH + + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/stale.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/stale.sql new file mode 100644 index 0000000000..18eae5e065 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/stale.sql @@ -0,0 +1 @@ +SELECT 'stale'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Preferences/com.tinyapp.TablePlus.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Preferences/com.tinyapp.TablePlus.plist new file mode 100644 index 0000000000..b068bee0e3 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Library/Preferences/com.tinyapp.TablePlus.plist @@ -0,0 +1,14 @@ + + + + + ViewSetting + + SharedConnectionPath + ~/Moved + SharedQueryPath + ~/Custom + + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Moved/ConnectionGroups.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Moved/ConnectionGroups.plist new file mode 100644 index 0000000000..da4fcf236a --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Moved/ConnectionGroups.plist @@ -0,0 +1,12 @@ + + + + + + ID + g1 + Name + Moved + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Moved/Connections.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Moved/Connections.plist new file mode 100644 index 0000000000..0b78644d66 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos-shared/Moved/Connections.plist @@ -0,0 +1,28 @@ + + + + + + ConnectionName + Moved DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + g1 + ID + c2 + isOverSSH + + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/.ssh/id_tableplus_fixture b/core/crates/foreign-import/tests/fixtures/tableplus/macos/.ssh/id_tableplus_fixture new file mode 100644 index 0000000000..c7f02e576c --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/.ssh/id_tableplus_fixture @@ -0,0 +1 @@ +not a key diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/ConnectionGroups.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/ConnectionGroups.plist new file mode 100644 index 0000000000..ed954ffea4 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/ConnectionGroups.plist @@ -0,0 +1,26 @@ + + + + + + ID + group-1 + Name + Production + + + ID + group-2 + Name + Development + + + GroupID + group-1 + ID + group-3 + Name + Replicas + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist new file mode 100644 index 0000000000..33a04d13a4 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist @@ -0,0 +1,2531 @@ + + + + + + ConnectionName + Keychain DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + conn-1 + isOverSSH + + + + ConnectionName + Bundle DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + conn-7 + isOverSSH + + + + ConnectionName + Conn MicrosoftSQLServer + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MicrosoftSQLServer + Enviroment + + GroupID + + ID + drv-0 + isOverSSH + + + + ConnectionName + Conn Mongo + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Mongo + Enviroment + + GroupID + + ID + drv-1 + isOverSSH + + + + ConnectionName + Conn Cockroach + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Cockroach + Enviroment + + GroupID + + ID + drv-2 + isOverSSH + + + + ConnectionName + Conn CloudflareD1 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + CloudflareD1 + Enviroment + + GroupID + + ID + drv-3 + isOverSSH + + + + ConnectionName + Conn LibSQL + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + LibSQL + Enviroment + + GroupID + + ID + drv-4 + isOverSSH + + + + ConnectionName + Conn ElasticSearch + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + ElasticSearch + Enviroment + + GroupID + + ID + drv-5 + isOverSSH + + + + ConnectionName + Conn MySQL + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + drv-6 + isOverSSH + + + + ConnectionName + Conn MariaDB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MariaDB + Enviroment + + GroupID + + ID + drv-7 + isOverSSH + + + + ConnectionName + Conn PostgreSQL + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + PostgreSQL + Enviroment + + GroupID + + ID + drv-8 + isOverSSH + + + + ConnectionName + Conn Redshift + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Redshift + Enviroment + + GroupID + + ID + drv-9 + isOverSSH + + + + ConnectionName + Conn Redis + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Redis + Enviroment + + GroupID + + ID + drv-10 + isOverSSH + + + + ConnectionName + Conn Oracle + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Oracle + Enviroment + + GroupID + + ID + drv-11 + isOverSSH + + + + ConnectionName + Conn SQLite + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + SQLite + Enviroment + + GroupID + + ID + drv-12 + isOverSSH + + + + ConnectionName + Conn DuckDB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + DuckDB + Enviroment + + GroupID + + ID + drv-13 + isOverSSH + + + + ConnectionName + Conn ClickHouse + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + ClickHouse + Enviroment + + GroupID + + ID + drv-14 + isOverSSH + + + + ConnectionName + Conn BigQuery + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + BigQuery + Enviroment + + GroupID + + ID + drv-15 + isOverSSH + + + + ConnectionName + Conn DynamoDB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + DynamoDB + Enviroment + + GroupID + + ID + drv-16 + isOverSSH + + + + ConnectionName + Conn Snowflake + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Snowflake + Enviroment + + GroupID + + ID + drv-17 + isOverSSH + + + + ConnectionName + Conn Cassandra + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Cassandra + Enviroment + + GroupID + + ID + drv-18 + isOverSSH + + + + ConnectionName + Conn Vertica + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Vertica + Enviroment + + GroupID + + ID + drv-19 + isOverSSH + + + + ConnectionName + Conn Greenplum + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Greenplum + Enviroment + + GroupID + + ID + drv-20 + isOverSSH + + + + ConnectionName + SSH DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssh-1 + ServerAddress + bastion.example.com + ServerPort + 2222 + ServerPrivateKeyName + /Users/test/.ssh/id_rsa + ServerUser + deploy + isOverSSH + + isUsePrivateKey + + + + ConnectionName + SSH Placeholder + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssh-placeholder + ServerAddress + bastion.example.com + ServerPort + 22 + ServerPrivateKeyName + Import a private key... + ServerUser + deploy + isOverSSH + + isUsePrivateKey + + + + ConnectionName + SSH Bare Key + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssh-bare + ServerAddress + bastion.example.com + ServerPort + 22 + ServerPrivateKeyName + id_tableplus_fixture + ServerUser + deploy + isOverSSH + + isUsePrivateKey + + + + ConnectionName + SSH Password DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssh-2 + ServerAddress + jump.example.com + ServerPort + 22 + ServerPrivateKeyName + + ServerUser + admin + isOverSSH + + isUsePrivateKey + + + + ConnectionName + SSH Integer Flags + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssh-int + ServerAddress + bastion.example.com + ServerPort + 22 + ServerPrivateKeyName + /keys/id_ed25519 + ServerUser + deploy + isOverSSH + 1 + isUsePrivateKey + 1 + + + ConnectionName + SSL DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssl-1 + TlsKeyPaths + + /path/to/client-key.pem + /path/to/client-cert.pem + /path/to/ca.pem + + isOverSSH + + tLSMode + 2 + + + ConnectionName + No SSL + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + nossl-1 + isOverSSH + + + + ConnectionName + Prefer SSL + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssl-prefer + isOverSSH + + tLSMode + 0 + + + ConnectionName + MySQL 0 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssl-0 + isOverSSH + + tLSMode + 0 + + + ConnectionName + MySQL 1 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssl-1 + isOverSSH + + tLSMode + 1 + + + ConnectionName + MySQL 2 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssl-2 + isOverSSH + + tLSMode + 2 + + + ConnectionName + MySQL 3 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssl-3 + isOverSSH + + tLSMode + 3 + + + ConnectionName + MySQL 4 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssl-4 + isOverSSH + + tLSMode + 4 + + + ConnectionName + MariaDB 1 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MariaDB + Enviroment + + GroupID + + ID + ssl-5 + isOverSSH + + tLSMode + 1 + + + ConnectionName + MariaDB 2 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MariaDB + Enviroment + + GroupID + + ID + ssl-6 + isOverSSH + + tLSMode + 2 + + + ConnectionName + PostgreSQL 1 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + PostgreSQL + Enviroment + + GroupID + + ID + ssl-7 + isOverSSH + + tLSMode + 1 + + + ConnectionName + PostgreSQL 2 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + PostgreSQL + Enviroment + + GroupID + + ID + ssl-8 + isOverSSH + + tLSMode + 2 + + + ConnectionName + PostgreSQL 3 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + PostgreSQL + Enviroment + + GroupID + + ID + ssl-9 + isOverSSH + + tLSMode + 3 + + + ConnectionName + PostgreSQL 4 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + PostgreSQL + Enviroment + + GroupID + + ID + ssl-10 + isOverSSH + + tLSMode + 4 + + + ConnectionName + PostgreSQL 5 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + PostgreSQL + Enviroment + + GroupID + + ID + ssl-11 + isOverSSH + + tLSMode + 5 + + + ConnectionName + Cockroach 5 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Cockroach + Enviroment + + GroupID + + ID + ssl-12 + isOverSSH + + tLSMode + 5 + + + ConnectionName + Redshift 4 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Redshift + Enviroment + + GroupID + + ID + ssl-13 + isOverSSH + + tLSMode + 4 + + + ConnectionName + Cassandra 0 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Cassandra + Enviroment + + GroupID + + ID + ssl-14 + isOverSSH + + tLSMode + 0 + + + ConnectionName + Cassandra 2 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Cassandra + Enviroment + + GroupID + + ID + ssl-15 + isOverSSH + + tLSMode + 2 + + + ConnectionName + Cassandra 4 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Cassandra + Enviroment + + GroupID + + ID + ssl-16 + isOverSSH + + tLSMode + 4 + + + ConnectionName + Redis 0 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Redis + Enviroment + + GroupID + + ID + ssl-17 + isOverSSH + + tLSMode + 0 + + + ConnectionName + Redis 2 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Redis + Enviroment + + GroupID + + ID + ssl-18 + isOverSSH + + tLSMode + 2 + + + ConnectionName + ClickHouse 1 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + ClickHouse + Enviroment + + GroupID + + ID + ssl-19 + isOverSSH + + tLSMode + 1 + + + ConnectionName + ClickHouse 2 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + ClickHouse + Enviroment + + GroupID + + ID + ssl-20 + isOverSSH + + tLSMode + 2 + + + ConnectionName + Mongo 1 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Mongo + Enviroment + + GroupID + + ID + ssl-21 + isOverSSH + + tLSMode + 1 + + + ConnectionName + ElasticSearch 1 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + ElasticSearch + Enviroment + + GroupID + + ID + ssl-22 + isOverSSH + + tLSMode + 1 + + + ConnectionName + ElasticSearch 2 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + ElasticSearch + Enviroment + + GroupID + + ID + ssl-23 + isOverSSH + + tLSMode + 2 + + + ConnectionName + ElasticSearch 3 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + ElasticSearch + Enviroment + + GroupID + + ID + ssl-24 + isOverSSH + + tLSMode + 3 + + + ConnectionName + Oracle 1 + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Oracle + Enviroment + + GroupID + + ID + ssl-25 + isOverSSH + + tLSMode + 1 + + + ConnectionName + Cassandra TLS + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Cassandra + Enviroment + + GroupID + + ID + tls-cassandra + TlsKeyPaths + + /path/to/cass-key.pem + /path/to/cass-cert.pem + /path/to/cass-ca.pem + + isOverSSH + + tLSMode + 2 + + + ConnectionName + Cassandra CA only + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Cassandra + Enviroment + + GroupID + + ID + tls-cassandra-ca + TlsKeyPaths + + + + /path/to/cass-ca.pem + + isOverSSH + + tLSMode + 3 + + + ConnectionName + Mongo TLS + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Mongo + Enviroment + + GroupID + + ID + tls-mongo + TlsKeyPaths + + /path/to/mongo-certkey.pem + /path/to/mongo-ca.pem + + isOverSSH + + tLSMode + 1 + + + ConnectionName + ClickHouse TLS + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + ClickHouse + Enviroment + + GroupID + + ID + tls-clickhouse + TlsKeyPaths + + /path/to/clickhouse-ca.pem + + isOverSSH + + tLSMode + 1 + + + ConnectionName + Elasticsearch TLS + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + ElasticSearch + Enviroment + + GroupID + + ID + tls-es + TlsKeyPaths + + /path/to/es-ca.pem + + isOverSSH + + tLSMode + 2 + + + ConnectionName + Oracle TLS + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Oracle + Enviroment + + GroupID + + ID + tls-oracle + TlsKeyPaths + + /stray/a.pem + /stray/b.pem + /stray/c.pem + + isOverSSH + + tLSMode + 1 + + + ConnectionName + Unknown TLS + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssl-unknown + isOverSSH + + tLSMode + 99 + + + ConnectionName + Past MySQL + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ssl-past-mysql + isOverSSH + + tLSMode + 5 + + + ConnectionName + Past Oracle + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Oracle + Enviroment + + GroupID + + ID + ssl-past-oracle + isOverSSH + + tLSMode + 2 + + + ConnectionName + SQL Server TLS + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MicrosoftSQLServer + Enviroment + + GroupID + + ID + ssl-mssql + isOverSSH + + tLSMode + 0 + + + ConnectionName + Snowflake TLS + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + Snowflake + Enviroment + + GroupID + + ID + ssl-snowflake + isOverSSH + + tLSMode + 0 + + + ConnectionName + Empty TLS + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + tls-empty + TlsKeyPaths + + + + + + isOverSSH + + tLSMode + 1 + + + ConnectionName + Prod DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + group-1 + ID + grp-1 + isOverSSH + + + + ConnectionName + Dev DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + group-2 + ID + grp-2 + isOverSSH + + + + ConnectionName + Ungrouped + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + grp-3 + isOverSSH + + + + ConnectionName + Replica DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + group-3 + ID + grp-4 + isOverSSH + + + + ConnectionName + Lost Group DB + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + missing-group + ID + grp-5 + isOverSSH + + + + ConnectionName + Custom Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 5433 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + port-1 + isOverSSH + + + + ConnectionName + Integer Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 6543 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + port-2 + isOverSSH + + + + ConnectionName + MySQL No Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + noport-0 + isOverSSH + + + + ConnectionName + PG No Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + + DatabaseUser + admin + Driver + PostgreSQL + Enviroment + + GroupID + + ID + noport-1 + isOverSSH + + + + ConnectionName + Mongo No Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + + DatabaseUser + admin + Driver + Mongo + Enviroment + + GroupID + + ID + noport-2 + isOverSSH + + + + ConnectionName + Redis No Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + + DatabaseUser + admin + Driver + Redis + Enviroment + + GroupID + + ID + noport-3 + isOverSSH + + + + ConnectionName + SQL Server No Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + + DatabaseUser + admin + Driver + MicrosoftSQLServer + Enviroment + + GroupID + + ID + noport-4 + isOverSSH + + + + ConnectionName + Cockroach No Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + + DatabaseUser + admin + Driver + Cockroach + Enviroment + + GroupID + + ID + noport-5 + isOverSSH + + + + ConnectionName + Redshift No Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + + DatabaseUser + admin + Driver + Redshift + Enviroment + + GroupID + + ID + noport-6 + isOverSSH + + + + ConnectionName + Vertica No Port + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + + DatabaseUser + admin + Driver + Vertica + Enviroment + + GroupID + + ID + noport-7 + isOverSSH + + + + DatabaseHost + localhost + Driver + MySQL + ID + invalid-1 + + + Driver + MySQL + ID + invalid-2 + + + ConnectionName + Staging + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + staging + GroupID + + ID + env-1 + isOverSSH + + + + ConnectionName + Prod + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + production + GroupID + + ID + env-2 + isOverSSH + + + + ConnectionName + Test + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + testing + GroupID + + ID + env-3 + isOverSSH + + + + ConnectionName + Dev + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + development + GroupID + + ID + env-4 + isOverSSH + + + + ConnectionName + None + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + env-5 + isOverSSH + + + + ConnectionName + Local SQLite + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePath + /Users/me/data.db + DatabasePort + 3306 + DatabaseUser + admin + Driver + SQLite + Enviroment + + GroupID + + ID + file-1 + isOverSSH + + + + ConnectionName + Local DuckDB + DatabaseHost + db.example.com + DatabaseName + + DatabasePath + /Users/me/warehouse.duckdb + DatabasePort + + DatabaseUser + admin + Driver + DuckDB + Enviroment + + GroupID + + ID + file-2 + isOverSSH + + + + ConnectionName + Server Path + DatabaseHost + db.example.com + DatabaseName + sales + DatabasePath + /tmp/stray + DatabasePort + 3306 + DatabaseUser + admin + Driver + MicrosoftSQLServer + Enviroment + + GroupID + + ID + file-3 + isOverSSH + + + + ConnectionName + Ask + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePasswordMode + 1 + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + pm-ask + isOverSSH + + + + ConnectionName + Stored + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePasswordMode + 0 + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + pm-stored + isOverSSH + + + + ConnectionName + Absent + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + pm-absent + isOverSSH + + + + ConnectionName + Unknown + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePasswordMode + 47 + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + pm-unknown + isOverSSH + + + + ConnectionName + No Password + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePasswordMode + 2 + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + pm-none + isOverSSH + + + + ConnectionName + Command + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePasswordMode + 3 + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + pm-command + isOverSSH + + + + ConnectionName + libSQL Ask + DatabaseHost + db.example.com + DatabaseName + + DatabasePasswordMode + 1 + DatabasePath + /Users/me/local.db + DatabasePort + + DatabaseUser + admin + Driver + LibSQL + Enviroment + + GroupID + + ID + pm-libsql + isOverSSH + + + + ConnectionName + DuckDB Ask + DatabaseHost + db.example.com + DatabaseName + + DatabasePasswordMode + 1 + DatabasePath + /Users/me/warehouse.duckdb + DatabasePort + + DatabaseUser + admin + Driver + DuckDB + Enviroment + + GroupID + + ID + pm-duckdb + isOverSSH + + + + ConnectionName + Ask SSH + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + pm-ask-ssh + ServerPasswordMode + 1 + isOverSSH + + + + ConnectionName + DynamoDB Ask + DatabaseHost + db.example.com + DatabaseName + mydb + DatabasePasswordMode + 1 + DatabasePort + 3306 + DatabaseUser + admin + Driver + DynamoDB + Enviroment + + GroupID + + ID + pm-dynamodb + isOverSSH + + + + ConnectionName + Orders + DatabaseHost + eu-west-1 + DatabaseName + + DatabasePort + + DatabaseUser + AKIAEXAMPLE + Driver + DynamoDB + Enviroment + + GroupID + + ID + ddb-1 + isOverSSH + + + + ConnectionName + Orders No Region + DatabaseHost + + DatabaseName + + DatabasePort + + DatabaseUser + + Driver + DynamoDB + Enviroment + + GroupID + + ID + ddb-2 + isOverSSH + + + + ConnectionName + Shop + DatabaseHost + eu-west-1 + DatabaseName + mydb + DatabasePort + 3306 + DatabaseUser + admin + Driver + MySQL + Enviroment + + GroupID + + ID + ddb-3 + isOverSSH + + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/.hidden.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/.hidden.sql new file mode 100644 index 0000000000..7b6c4ff5a9 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/.hidden.sql @@ -0,0 +1 @@ +SELECT 0; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/Sub/deep.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/Sub/deep.sql new file mode 100644 index 0000000000..c105264f33 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/Sub/deep.sql @@ -0,0 +1 @@ +SELECT 'Việt ✓'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/nested.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/nested.sql new file mode 100644 index 0000000000..fd7f9bebe3 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/nested.sql @@ -0,0 +1,2 @@ +-- c +SELECT 3; diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/nested.sql.tag b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/nested.sql.tag new file mode 100644 index 0000000000..c72bddaf47 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/nested.sql.tag @@ -0,0 +1 @@ +kwnested \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/notes.txt b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/notes.txt new file mode 100644 index 0000000000..0399d85024 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/notes.txt @@ -0,0 +1 @@ +SELECT 5; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/two.dots.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/two.dots.sql new file mode 100644 index 0000000000..b8a70a6851 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Folder A/two.dots.sql @@ -0,0 +1 @@ +SELECT 4; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Untitled.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/Untitled.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/orphan.sql.tag b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/orphan.sql.tag new file mode 100644 index 0000000000..c1b0730e01 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/orphan.sql.tag @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_root_kw.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_root_kw.sql new file mode 100644 index 0000000000..9a322c0ecc --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_root_kw.sql @@ -0,0 +1 @@ +SELECT 2; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_root_kw.sql.tag b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_root_kw.sql.tag new file mode 100644 index 0000000000..fd4e2d38e8 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_root_kw.sql.tag @@ -0,0 +1 @@ +kwone,kw two \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_top.sql b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_top.sql new file mode 100644 index 0000000000..027b7d63fd --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/macos/Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_top.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/ConnectionGroups.plist b/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/ConnectionGroups.plist new file mode 100644 index 0000000000..be677d0494 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/ConnectionGroups.plist @@ -0,0 +1,46 @@ + + + + + + GroupID + + ID + w-root + IsExpanded + 1 + Name + Servers + + + GroupID + w-root + ID + w-eu + IsExpanded + 1 + Name + EU + + + GroupID + w-eu + ID + w-prod + IsExpanded + 0 + Name + Prod + + + GroupID + w-prod + ID + w-deep + IsExpanded + 0 + Name + Deep + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Connections.plist b/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Connections.plist new file mode 100644 index 0000000000..40724326bc --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Connections.plist @@ -0,0 +1,130 @@ + + + + + + ConnectionName + Win MySQL + DatabaseHost + 10.0.0.5 + DatabaseName + shop + DatabasePasswordMode + 0 + DatabasePort + 3306 + DatabaseUser + root + Driver + MySQL + Enviroment + production + GroupID + w-eu + ID + win-1 + ServerAddress + bastion + ServerPasswordMode + 0 + ServerPort + 22 + ServerPrivateKeyName + C:\Users\me\.ssh\id_ed25519 + ServerUser + ops + TlsKeyPaths + + C:\tls\client.key + C:\tls\client.crt + C:\tls\ca.pem + + isOverSSH + 1 + isUsePrivateKey + 1 + tLSMode + 2 + + + ConnectionName + Win SQLite + DatabaseHost + C:\data\app.db + DatabaseName + + DatabasePasswordMode + 0 + DatabasePort + + DatabaseUser + + Driver + SQLite + Enviroment + + GroupID + w-deep + ID + win-2 + ServerPasswordMode + 0 + isOverSSH + 0 + + + ConnectionName + Win DuckDB + DatabaseHost + C:\data\lake.duckdb + DatabaseName + + DatabasePasswordMode + 0 + DatabasePort + + DatabaseUser + + Driver + DuckDB + Enviroment + + GroupID + + ID + win-3 + ServerPasswordMode + 0 + isOverSSH + 0 + + + ConnectionName + Win Postgres + DatabaseHost + pg.local + DatabaseName + + DatabasePasswordMode + 1 + DatabasePort + + DatabaseUser + + Driver + postgresql + Enviroment + + GroupID + + ID + win-4 + ServerPasswordMode + 0 + isOverSSH + 0 + tLSMode + 4 + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Favorite/report.sql b/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Favorite/report.sql new file mode 100644 index 0000000000..027b7d63fd --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Favorite/report.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Favorite/report.sql.tag b/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Favorite/report.sql.tag new file mode 100644 index 0000000000..0bdb597ac7 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/windows/AppData/Local/com.tinyapp.TablePlus/Data/Favorite/report.sql.tag @@ -0,0 +1 @@ +daily \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/windows/Queries/Favorite/moved.sql b/core/crates/foreign-import/tests/fixtures/tableplus/windows/Queries/Favorite/moved.sql new file mode 100644 index 0000000000..eaa0aca77e --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/windows/Queries/Favorite/moved.sql @@ -0,0 +1 @@ +SELECT 'moved'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/windows/Team Queries/team.sql b/core/crates/foreign-import/tests/fixtures/tableplus/windows/Team Queries/team.sql new file mode 100644 index 0000000000..90f1fdd3ee --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/windows/Team Queries/team.sql @@ -0,0 +1 @@ +SELECT 'team'; \ No newline at end of file diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/windows/Team/ConnectionGroups.plist b/core/crates/foreign-import/tests/fixtures/tableplus/windows/Team/ConnectionGroups.plist new file mode 100644 index 0000000000..b36b2434c5 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/windows/Team/ConnectionGroups.plist @@ -0,0 +1,16 @@ + + + + + + GroupID + + ID + t-1 + IsExpanded + 1 + Name + Shared + + + diff --git a/core/crates/foreign-import/tests/fixtures/tableplus/windows/Team/Connections.plist b/core/crates/foreign-import/tests/fixtures/tableplus/windows/Team/Connections.plist new file mode 100644 index 0000000000..52143352ec --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/tableplus/windows/Team/Connections.plist @@ -0,0 +1,32 @@ + + + + + + ConnectionName + Team Redis + DatabaseHost + cache.local + DatabaseName + + DatabasePasswordMode + 0 + DatabasePort + + DatabaseUser + + Driver + Redis + Enviroment + + GroupID + t-1 + ID + team-1 + ServerPasswordMode + 0 + isOverSSH + 0 + + + diff --git a/core/crates/foreign-import/tests/navicat.rs b/core/crates/foreign-import/tests/navicat.rs new file mode 100644 index 0000000000..d416c56577 --- /dev/null +++ b/core/crates/foreign-import/tests/navicat.rs @@ -0,0 +1,395 @@ +#![expect( + clippy::expect_used, + reason = "the fixture helpers fail a test the way the test functions themselves do" +)] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use secrecy::ExposeSecret; +use tablepro_foreign_import::{ + CollectedConnection, CollectedImport, ForeignApp, ForeignAppDirs, ForeignHost, ForeignImportError, ForeignImporter, + ForeignPlatform, ForeignSshAuth, ForeignSslMode, ImportRequest, Inventory, PasswordOutcome, PasswordSource, + PlainHost, SavedQuerySupport, +}; +use tokio_util::sync::CancellationToken; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("navicat") + .join(name) +} + +struct MissingApp; + +impl ForeignHost for MissingApp { + fn installed(&self, _app: ForeignApp) -> Option { + Some(false) + } +} + +fn bare_importer(platform: ForeignPlatform, host: Arc) -> (ForeignImporter, tempfile::TempDir) { + let home = tempfile::tempdir().expect("a temp dir"); + let importer = ForeignImporter::new( + ForeignApp::Navicat, + ForeignAppDirs::conventional(platform, home.path()), + host, + ); + (importer, home) +} + +fn import_of( + file: PathBuf, + platform: ForeignPlatform, + request: &ImportRequest, +) -> Result { + let (importer, _home) = bare_importer(platform, Arc::new(PlainHost)); + importer + .with_file(file) + .collect_blocking(request, &CancellationToken::new()) +} + +fn import(name: &str, request: &ImportRequest) -> Result { + import_of(fixture(name), ForeignPlatform::current(), request) +} + +fn connections(request: &ImportRequest) -> CollectedImport { + import("connections.ncx", request).expect("an import") +} + +fn request(include_passwords: bool, include_saved_queries: bool) -> ImportRequest { + ImportRequest { + include_passwords, + include_saved_queries, + saved_query_byte_limit: 64 * 1024, + } +} + +fn connection<'a>(import: &'a CollectedImport, name: &str) -> &'a CollectedConnection { + import + .connections + .iter() + .find(|connection| connection.settings.name == name) + .expect("the connection") +} + +fn password(connection: &CollectedConnection) -> Option<&str> { + connection + .credentials + .as_ref() + .and_then(|credentials| credentials.password.as_ref()) + .map(ExposeSecret::expose_secret) +} + +fn ssh_password(connection: &CollectedConnection) -> Option<&str> { + connection + .credentials + .as_ref() + .and_then(|credentials| credentials.ssh_password.as_ref()) + .map(ExposeSecret::expose_secret) +} + +#[test] +fn the_app_is_known_by_its_id_and_reads_a_picked_ncx_export() { + let app = ForeignApp::Navicat; + assert_eq!(app.id(), "navicat"); + assert_eq!(app.display_name(), "Navicat"); + assert_eq!(ForeignApp::from_id("navicat"), Some(app)); + assert_eq!(app.picked_file_extensions(), Some(&["ncx"][..])); + assert_eq!(app.password_source(), PasswordSource::AppFiles); + assert!(app.runs_on(ForeignPlatform::MacOs)); + assert!(app.runs_on(ForeignPlatform::Linux)); + assert!(app.runs_on(ForeignPlatform::Windows)); +} + +#[test] +fn saved_queries_are_not_in_the_export() { + assert_eq!( + ForeignApp::Navicat.saved_query_support(), + SavedQuerySupport::NotInExport + ); +} + +#[test] +fn is_available_without_an_installed_app_or_a_file_on_every_platform() { + for platform in [ForeignPlatform::MacOs, ForeignPlatform::Linux, ForeignPlatform::Windows] { + let (importer, _home) = bare_importer(platform, Arc::new(MissingApp)); + assert!(importer.is_available_blocking()); + let (importer, _home) = bare_importer(platform, Arc::new(PlainHost)); + assert!(importer.is_available_blocking()); + } +} + +#[test] +fn reads_the_same_export_on_every_platform() { + for platform in [ForeignPlatform::MacOs, ForeignPlatform::Linux, ForeignPlatform::Windows] { + let import = import_of(fixture("connections.ncx"), platform, &request(true, false)).expect("an import"); + assert_eq!(import.connections.len(), 10); + assert_eq!(password(connection(&import, "Zero")), Some("This is a test")); + } +} + +#[test] +fn inventory_is_empty_without_a_file() { + let (importer, _home) = bare_importer(ForeignPlatform::current(), Arc::new(PlainHost)); + assert_eq!(importer.inventory_blocking(), Inventory::default()); +} + +#[test] +fn inventory_counts_the_files_connections_and_never_any_saved_queries() { + let (importer, _home) = bare_importer(ForeignPlatform::current(), Arc::new(PlainHost)); + let inventory = importer.with_file(fixture("connections.ncx")).inventory_blocking(); + assert_eq!( + inventory, + Inventory { + connections: 10, + saved_queries: 0, + } + ); +} + +#[test] +fn asking_for_saved_queries_still_imports_connections_only() { + let import = connections(&request(true, true)); + assert_eq!(import.connections.len(), 10); + assert!(import.saved_queries.is_empty()); +} + +#[test] +fn importing_without_a_file_fails() { + let (importer, _home) = bare_importer(ForeignPlatform::current(), Arc::new(PlainHost)); + let outcome = importer.collect_blocking(&request(true, false), &CancellationToken::new()); + assert!(matches!( + outcome, + Err(ForeignImportError::FileNotFound { + app: ForeignApp::Navicat + }) + )); +} + +#[test] +fn a_file_that_cannot_be_read_is_not_found() { + let outcome = import("missing.ncx", &request(true, false)); + assert!(matches!( + outcome, + Err(ForeignImportError::FileNotFound { + app: ForeignApp::Navicat + }) + )); +} + +#[test] +fn malformed_xml_is_a_parse_error() { + let outcome = import("malformed.ncx", &request(true, false)); + assert!(matches!(outcome, Err(ForeignImportError::Parse { .. }))); +} + +#[test] +fn an_export_with_a_document_type_is_refused() { + let outcome = import("doctype.ncx", &request(true, false)); + assert!(matches!(outcome, Err(ForeignImportError::Parse { .. }))); +} + +#[test] +fn a_file_with_no_connections_has_nothing_to_import() { + let outcome = import("empty.ncx", &request(true, false)); + assert!(matches!(outcome, Err(ForeignImportError::NothingFound))); +} + +#[test] +fn maps_every_known_conn_type() { + let import = connections(&request(false, false)); + let engines: Vec<&str> = import.connections[..7] + .iter() + .map(|connection| connection.settings.engine.as_str()) + .collect(); + assert_eq!( + engines, + [ + "MySQL", + "PostgreSQL", + "MariaDB", + "Oracle", + "SQLite", + "SQL Server", + "MongoDB" + ] + ); +} + +#[test] +fn passes_an_unknown_conn_type_through_with_no_port() { + let import = connections(&request(false, false)); + let exotic = connection(&import, "Exotic"); + assert_eq!(exotic.settings.engine, "EXOTICDB"); + assert_eq!(exotic.settings.port, 0); +} + +#[test] +fn resolves_a_conn_type_tablepro_knows_under_another_case() { + let import = connections(&request(false, false)); + let cockroach = connection(&import, "Cockroach"); + assert_eq!(cockroach.settings.engine, "CockroachDB"); + assert_eq!(cockroach.settings.port, 26_257); +} + +#[test] +fn falls_back_to_the_default_port_when_port_is_empty() { + let import = connections(&request(false, false)); + let postgres = connection(&import, "One"); + assert_eq!(postgres.settings.port, 5432); + assert_eq!(postgres.settings.host, "pg.example.com"); + assert_eq!(postgres.settings.database, "analytics"); + assert_eq!(postgres.settings.username, "analyst"); +} + +#[test] +fn maps_sqlite_to_its_file_path() { + let import = connections(&request(false, false)); + let sqlite = connection(&import, "Files"); + assert_eq!(sqlite.settings.engine, "SQLite"); + assert_eq!(sqlite.settings.database, "/Users/me/data.db"); + assert_eq!(sqlite.settings.host, ""); + assert_eq!(sqlite.settings.port, 0); + assert_eq!(sqlite.settings.username, ""); +} + +#[test] +fn maps_an_ssh_tunnel() { + let import = connections(&request(false, false)); + let ssh = connection(&import, "Zero").settings.ssh.as_ref().expect("a tunnel"); + assert_eq!(ssh.host, "bastion.example.com"); + assert_eq!(ssh.port, Some(2222)); + assert_eq!(ssh.username, "deploy"); + assert_eq!(ssh.auth, ForeignSshAuth::Password); + assert_eq!(ssh.private_key_path, ""); + assert!(connection(&import, "One").settings.ssh.is_none()); +} + +#[test] +fn a_tunnel_that_does_not_use_a_password_uses_a_key() { + let import = connections(&request(false, false)); + let ssh = connection(&import, "Reports & BI") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, "~/.ssh/id_rsa"); + assert_eq!(ssh.port, None); +} + +#[test] +fn maps_ssl_verify_mode_and_ca_path() { + let import = connections(&request(false, false)); + let ssl = connection(&import, "One").settings.ssl.as_ref().expect("ssl"); + assert_eq!(ssl.mode, ForeignSslMode::VerifyCa); + assert_eq!(ssl.ca_certificate_path.as_deref(), Some("/certs/ca.pem")); + assert_eq!(ssl.client_certificate_path, None); + assert_eq!(ssl.client_key_path, None); + assert!(connection(&import, "Zero").settings.ssl.is_none()); +} + +#[test] +fn verify_full_is_verify_identity_with_the_client_certificate() { + let import = connections(&request(false, false)); + let ssl = connection(&import, "Reports & BI").settings.ssl.as_ref().expect("ssl"); + assert_eq!(ssl.mode, ForeignSslMode::VerifyIdentity); + assert_eq!(ssl.ca_certificate_path, None); + assert_eq!(ssl.client_certificate_path.as_deref(), Some("/certs/client.pem")); + assert_eq!(ssl.client_key_path.as_deref(), Some("/certs/client.key")); +} + +#[test] +fn flags_and_types_match_in_any_case_and_blanks_take_defaults() { + let import = connections(&request(false, false)); + let unnamed = &import.connections[9]; + assert_eq!(unnamed.settings.name, "Navicat"); + assert_eq!(unnamed.settings.host, "localhost"); + assert_eq!(unnamed.settings.engine, "MySQL"); + assert_eq!( + unnamed.settings.ssl.as_ref().map(|ssl| ssl.mode), + Some(ForeignSslMode::Preferred) + ); +} + +#[test] +fn decrypts_the_database_and_ssh_passwords() { + let import = connections(&request(true, false)); + let zero = connection(&import, "Zero"); + assert_eq!(password(zero), Some("This is a test")); + assert_eq!(ssh_password(zero), Some("This is a test")); +} + +#[test] +fn maps_credentials_to_the_right_connection_when_some_have_no_saved_password() { + let import = connections(&request(true, false)); + assert_eq!(import.passwords, PasswordOutcome::Read); + assert_eq!( + import + .connections + .iter() + .filter(|connection| connection.credentials.is_some()) + .count(), + 2 + ); + assert_eq!(password(&import.connections[0]), Some("This is a test")); + assert!(import.connections[1].credentials.is_none()); + assert_eq!(password(&import.connections[2]), Some("Sup3rSecret!Pass")); +} + +#[test] +fn skips_the_password_when_save_password_is_false() { + let import = connections(&request(true, false)); + assert!(connection(&import, "One").credentials.is_none()); +} + +#[test] +fn a_password_that_does_not_decrypt_is_left_out() { + let import = connections(&request(true, false)); + assert!(import.connections[9].credentials.is_none()); +} + +#[test] +fn skips_passwords_entirely_when_they_are_not_requested() { + let import = connections(&request(false, false)); + assert_eq!(import.passwords, PasswordOutcome::NotRequested); + assert!( + import + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); +} + +#[test] +fn stamps_each_connection_with_a_row_reference() { + let import = connections(&request(true, false)); + assert_eq!(import.app, ForeignApp::Navicat); + let references: Vec<&str> = import + .connections + .iter() + .map(|connection| connection.reference.as_str()) + .collect(); + let expected: Vec = (0..10).map(|index| format!("row-{index}")).collect(); + assert_eq!(references, expected); + assert!( + import + .connections + .iter() + .all(|connection| connection.group_path.is_empty()) + ); +} + +#[test] +fn reading_stops_when_cancelled() { + let cancel = CancellationToken::new(); + cancel.cancel(); + let (importer, _home) = bare_importer(ForeignPlatform::current(), Arc::new(PlainHost)); + let outcome = importer + .with_file(fixture("connections.ncx")) + .collect_blocking(&request(true, false), &cancel); + assert!(matches!(outcome, Err(ForeignImportError::Cancelled))); +} diff --git a/core/crates/foreign-import/tests/review.rs b/core/crates/foreign-import/tests/review.rs new file mode 100644 index 0000000000..fbe7781a86 --- /dev/null +++ b/core/crates/foreign-import/tests/review.rs @@ -0,0 +1,1924 @@ +use std::collections::BTreeMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; + +use secrecy::{ExposeSecret, SecretString}; +use tablepro_core::credentials::SecretKind; +use tablepro_core::meta::ObjectKind; +use tablepro_core::{ + AiPolicy, AuthMode, ClientIdentity, CommitResolutionSupport, ConnectOptions, Connection, DataModel, DatabaseDriver, + DriverCapabilities, DriverError, DriverRegistry, EndpointKind, EstimateQuality, ExternalAccess, FileFormat, + FileSignature, NetworkOption, NetworkOptions, ReadOnlyEnforcement, SafeModeLevel, ScriptExecution, + ServerNameOverride, StatementStop, TlsCapabilities, TlsConfig, TlsMode, WriteAtomicity, +}; +use tablepro_foreign_import::review::{ + ConnectionRow, ExistingConnection, ImportReview, ImportSelection, QueryAvailability, QueryStatus, QueryTarget, + Resolution, ReviewInputs, RowNote, TlsFileReason, apply_blocking, plan, save_queries, +}; +use tablepro_foreign_import::{ + CollectedConnection, CollectedImport, CollectedSavedQuery, ForeignApp, ForeignAppDirs, ForeignColor, + ForeignConnection, ForeignCredentials, ForeignJumpHost, ForeignPlatform, ForeignSsh, ForeignSshAuth, ForeignSsl, + ForeignSslMode, PasswordOutcome, SavedQueryContent, +}; +use tablepro_storage::saved_queries::NewSavedQuery; +use tablepro_storage::{ + ConnectionColor, ConnectionList, ConnectionStore, QueryHistory, SavedConnection, SavedSshAuth, SavedSshConfig, + StorageError, StoragePaths, +}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const ALL_MODES: &[TlsMode] = &[TlsMode::Disable, TlsMode::Require, TlsMode::VerifyFull]; + +const FULL_TLS: TlsCapabilities = TlsCapabilities { + modes: ALL_MODES, + ca_file: true, + client_identity: true, + server_name_override: ServerNameOverride::AnyRoute, +}; + +const fn network(default_port: u16, data_model: DataModel, tls: Option) -> DriverCapabilities { + DriverCapabilities { + data_model, + endpoint: EndpointKind::Network { default_port }, + auth_modes: &[AuthMode::Password], + username_required: true, + tls, + read_only: ReadOnlyEnforcement::SessionDefault, + dml_writes: WriteAtomicity::Transactional, + ddl_writes: WriteAtomicity::Transactional, + script_execution: ScriptExecution::NativeBatch, + commit_resolution: CommitResolutionSupport::Unavailable, + row_count_estimate: EstimateQuality::Statistics, + object_kinds: &[ObjectKind::Table], + database_required: false, + network_options: &[], + statement_stop: StatementStop::Server, + } +} + +const fn file(suffix: &'static [&'static str]) -> DriverCapabilities { + DriverCapabilities { + endpoint: EndpointKind::File(FileFormat { + signature: FileSignature { + offset: 0, + bytes: b"Test format\0", + }, + suffixes: suffix, + mime_types: &[], + empty_file_is_database: true, + }), + auth_modes: &[], + tls: None, + ..network(0, DataModel::Relational, None) + } +} + +static MYSQL: DriverCapabilities = network(3306, DataModel::Relational, Some(FULL_TLS)); +static POSTGRES: DriverCapabilities = network(5432, DataModel::Relational, Some(FULL_TLS)); +static MSSQL: DriverCapabilities = network( + 1433, + DataModel::Relational, + Some(TlsCapabilities { + client_identity: false, + ..FULL_TLS + }), +); +static CLICKHOUSE: DriverCapabilities = network(8123, DataModel::Relational, Some(FULL_TLS)); +static CASSANDRA: DriverCapabilities = network(9042, DataModel::Relational, Some(FULL_TLS)); +static REDIS: DriverCapabilities = network(6379, DataModel::KeyValue, Some(FULL_TLS)); +static MONGODB: DriverCapabilities = DriverCapabilities { + network_options: &[NetworkOption::DnsSeedList, NetworkOption::AuthDatabase], + ..network(27017, DataModel::Document, Some(FULL_TLS)) +}; +static SQLITE: DriverCapabilities = file(&["sqlite"]); +static DUCKDB: DriverCapabilities = file(&["duckdb"]); +static VERIFY_ONLY: DriverCapabilities = network( + 5432, + DataModel::Relational, + Some(TlsCapabilities { + modes: &[TlsMode::VerifyFull], + ca_file: false, + ..FULL_TLS + }), +); +static NO_VERIFY: DriverCapabilities = network( + 5432, + DataModel::Relational, + Some(TlsCapabilities { + modes: &[TlsMode::Disable, TlsMode::Require], + ..FULL_TLS + }), +); +static NO_TLS: DriverCapabilities = network(5432, DataModel::Relational, None); + +struct TestDriver { + id: &'static str, + capabilities: &'static DriverCapabilities, +} + +// Written out as `async_trait` expands it, which this crate does not depend on. +impl DatabaseDriver for TestDriver { + fn id(&self) -> &'static str { + self.id + } + + fn display_name(&self) -> &'static str { + self.id + } + + fn capabilities(&self) -> &'static DriverCapabilities { + self.capabilities + } + + fn connect<'life0, 'async_trait>( + &'life0 self, + _options: ConnectOptions, + _cancel: CancellationToken, + ) -> Pin, DriverError>> + Send + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async { Err(DriverError::Cancelled) }) + } +} + +fn registry(drivers: &[(&'static str, &'static DriverCapabilities)]) -> DriverRegistry { + let mut registry = DriverRegistry::new(); + for (id, capabilities) in drivers { + registry.register(Arc::new(TestDriver { id, capabilities })); + } + registry +} + +fn drivers() -> DriverRegistry { + registry(&[ + ("mysql", &MYSQL), + ("postgres", &POSTGRES), + ("mssql", &MSSQL), + ("clickhouse", &CLICKHOUSE), + ("cassandra", &CASSANDRA), + ("redis", &REDIS), + ("mongodb", &MONGODB), + ("sqlite", &SQLITE), + ("duckdb", &DUCKDB), + ]) +} + +fn home() -> PathBuf { + if cfg!(windows) { + PathBuf::from(r"C:\Users\u") + } else { + PathBuf::from("/home/u") + } +} + +fn dirs() -> ForeignAppDirs { + ForeignAppDirs::conventional(ForeignPlatform::Linux, &home()) +} + +fn settings() -> ForeignConnection { + ForeignConnection { + name: "Orders".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "orders".to_owned(), + username: "app".to_owned(), + engine: "PostgreSQL".to_owned(), + ssh: None, + ssl: None, + color: None, + additional_fields: BTreeMap::new(), + } +} + +fn named(name: &str) -> ForeignConnection { + ForeignConnection { + name: name.to_owned(), + ..settings() + } +} + +fn on_host(name: &str, host: &str) -> ForeignConnection { + ForeignConnection { + host: host.to_owned(), + ..named(name) + } +} + +fn connection(reference: &str, settings: ForeignConnection) -> CollectedConnection { + CollectedConnection { + reference: reference.to_owned(), + settings, + group_path: Vec::new(), + credentials: None, + } +} + +fn query(reference: &str, name: &str, text: &str, connection: Option<&str>) -> CollectedSavedQuery { + CollectedSavedQuery { + reference: reference.to_owned(), + name: name.to_owned(), + content: SavedQueryContent::Text(text.to_owned()), + keyword: None, + folder_path: Vec::new(), + connection: connection.map(str::to_owned), + suggested: true, + } +} + +fn collected(connections: Vec, saved_queries: Vec) -> CollectedImport { + CollectedImport { + app: ForeignApp::DBeaver, + connections, + saved_queries, + passwords: PasswordOutcome::Read, + } +} + +fn library(connections: Vec) -> ConnectionList { + ConnectionList { + connections: Arc::from(connections), + ..ConnectionList::default() + } +} + +fn review_in( + collected: CollectedImport, + library: ConnectionList, + drivers: &DriverRegistry, + file_exists: &dyn Fn(&Path) -> bool, +) -> ImportReview { + let dirs = dirs(); + ImportReview::analyze( + collected, + ReviewInputs { + drivers, + library, + dirs: &dirs, + file_exists, + }, + ) +} + +fn review(collected: CollectedImport, saved: Vec) -> ImportReview { + review_in(collected, library(saved), &drivers(), &|_| true) +} + +fn only_row(settings: ForeignConnection) -> ConnectionRow { + review_with(settings, &drivers()).connections()[0].clone() +} + +fn review_with(settings: ForeignConnection, drivers: &DriverRegistry) -> ImportReview { + review_in( + collected(vec![connection("c1", settings)], Vec::new()), + library(Vec::new()), + drivers, + &|_| true, + ) +} + +fn mapped(settings: ForeignConnection) -> Option { + only_row(settings).connection +} + +// A saved connection as this import writes the same settings, under an id of its own. +fn existing(settings: ForeignConnection, name: &str) -> Option { + mapped(settings).map(|saved| SavedConnection { + id: Uuid::new_v4(), + name: name.to_owned(), + ..saved + }) +} + +fn secret(value: &str) -> Option { + Some(SecretString::from(value.to_owned())) +} + +fn password(value: &str) -> Option { + Some(ForeignCredentials { + password: secret(value), + ..ForeignCredentials::default() + }) +} + +fn ssl(mode: ForeignSslMode) -> Option { + Some(ForeignSsl { + mode, + ca_certificate_path: None, + client_certificate_path: None, + client_key_path: None, + }) +} + +fn tunnel(host: &str, auth: ForeignSshAuth, key: &str) -> Option { + Some(ForeignSsh { + host: host.to_owned(), + port: None, + username: "deploy".to_owned(), + auth, + private_key_path: key.to_owned(), + jump_hosts: Vec::new(), + }) +} + +fn hop(host: &str, port: Option, username: &str, auth: ForeignSshAuth, key: &str) -> ForeignJumpHost { + ForeignJumpHost { + host: host.to_owned(), + port, + username: username.to_owned(), + auth, + private_key_path: key.to_owned(), + } +} + +#[test] +fn a_connection_matching_host_port_database_and_username_ignoring_case_and_spaces_is_a_duplicate() { + let local = existing(named("Local Postgres"), "Local Postgres").expect("a saved connection"); + let imported = ForeignConnection { + name: "Different Name".to_owned(), + host: " db.example.com ".to_owned(), + database: " ORDERS ".to_owned(), + username: " APP ".to_owned(), + engine: "MySQL".to_owned(), + ..settings() + }; + let review = review( + collected(vec![connection("c1", imported)], Vec::new()), + vec![local.clone()], + ); + + let row = &review.connections()[0]; + assert_eq!( + row.duplicate, + Some(ExistingConnection { + id: local.id, + name: "Local Postgres".to_owned() + }) + ); + // Another engine signs in differently, so Replace would hand it the saved password. + assert_eq!(row.resolutions, [Resolution::AddCopy]); + assert!(!row.selected_by_default); +} + +#[test] +fn another_ssh_server_tls_mode_or_engine_is_not_offered_replace() { + let saved = ForeignConnection { + ssl: ssl(ForeignSslMode::VerifyIdentity), + ssh: tunnel("bastion.prod.internal", ForeignSshAuth::Agent, ""), + ..settings() + }; + let local = existing(saved.clone(), "Production").expect("a saved connection"); + let variants = [ + ForeignConnection { + ssh: tunnel("bastion.attacker.example", ForeignSshAuth::Agent, ""), + ..saved.clone() + }, + ForeignConnection { + ssh: None, + ..saved.clone() + }, + ForeignConnection { + ssl: ssl(ForeignSslMode::Required), + ..saved.clone() + }, + ForeignConnection { + engine: "MySQL".to_owned(), + ..saved.clone() + }, + ]; + for imported in variants { + let review = review( + collected(vec![connection("c1", imported)], Vec::new()), + vec![local.clone()], + ); + let row = &review.connections()[0]; + assert_eq!(row.duplicate.as_ref().map(|duplicate| duplicate.id), Some(local.id)); + assert_eq!(row.resolutions, [Resolution::AddCopy]); + } +} + +#[test] +fn a_duplicate_that_differs_only_in_how_it_is_shown_is_offered_replace() { + let saved = ForeignConnection { + ssl: ssl(ForeignSslMode::VerifyIdentity), + ..settings() + }; + let local = existing(saved.clone(), "Production").expect("a saved connection"); + let imported = ForeignConnection { + name: "Production (laptop)".to_owned(), + color: Some(ForeignColor::Red), + host: " db.example.com ".to_owned(), + ..saved + }; + let review = review( + collected(vec![connection("c1", imported)], Vec::new()), + vec![local.clone()], + ); + + assert_eq!( + review.connections()[0].resolutions, + [Resolution::AddCopy, Resolution::Replace(local.id)] + ); +} + +#[test] +fn a_duplicate_whose_saved_connection_guards_more_is_offered_as_copy_only() { + let base = existing(settings(), "Production").expect("a saved connection"); + let guarded = [ + SavedConnection { + safe_mode: SafeModeLevel::ConfirmWrites, + ..base.clone() + }, + SavedConnection { + ai_policy: AiPolicy::Never, + ..base.clone() + }, + SavedConnection { + query_timeout_secs: Some(30), + ..base.clone() + }, + ]; + for local in guarded { + let review = review(collected(vec![connection("c1", settings())], Vec::new()), vec![local]); + assert_eq!(review.connections()[0].resolutions, [Resolution::AddCopy]); + } +} + +#[test] +fn the_first_library_connection_with_a_matching_key_is_the_duplicate() { + let first = existing(settings(), "First").expect("a saved connection"); + let second = existing(settings(), "Second").expect("a saved connection"); + let review = review( + collected(vec![connection("c1", settings())], Vec::new()), + vec![first.clone(), second], + ); + + assert_eq!( + review.connections()[0].duplicate.as_ref().map(|duplicate| duplicate.id), + Some(first.id) + ); +} + +#[test] +fn a_different_username_on_the_same_host_is_not_a_duplicate() { + let local = existing(settings(), "Orders").expect("a saved connection"); + let imported = ForeignConnection { + username: "readonly".to_owned(), + ..settings() + }; + let review = review(collected(vec![connection("c1", imported)], Vec::new()), vec![local]); + + let row = &review.connections()[0]; + assert_eq!(row.duplicate, None); + assert_eq!(row.resolutions, [Resolution::Add]); + assert!(row.selected_by_default); +} + +#[test] +fn redis_connections_differ_by_database_index_and_none_named_is_index_zero() { + let redis = |database: &str| ForeignConnection { + port: 6379, + database: database.to_owned(), + engine: "Redis".to_owned(), + ..named("Cache") + }; + let local = existing(redis("0"), "Cache").expect("a saved connection"); + let review = review( + collected( + vec![connection("c1", redis("1")), connection("c2", redis(""))], + Vec::new(), + ), + vec![local], + ); + + assert_eq!(review.connections()[0].duplicate, None); + assert!(review.connections()[1].duplicate.is_some()); +} + +#[test] +fn an_engine_no_driver_here_opens_is_unsupported_kept_as_written_and_not_selectable() { + let without_mysql = registry(&[("postgres", &POSTGRES)]); + for (engine, drivers) in [("Vertica", drivers()), ("MySQL", without_mysql)] { + let imported = ForeignConnection { + engine: engine.to_owned(), + ..settings() + }; + let review = review_with(imported, &drivers); + let row = &review.connections()[0]; + assert_eq!(row.engine, engine); + assert_eq!(row.connection, None); + assert_eq!( + row.notes, + [RowNote::UnsupportedEngine { + engine: engine.to_owned() + }] + ); + assert!(row.resolutions.is_empty()); + assert!(!row.selected_by_default); + + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c1", &review); + assert_eq!(selection.resolution("c1"), None); + assert!(plan(&review, &selection).is_empty()); + } +} + +#[test] +fn an_engine_differing_only_in_case_is_matched() { + let row = only_row(ForeignConnection { + engine: "postgresql".to_owned(), + ..settings() + }); + assert_eq!(row.connection.map(|saved| saved.driver_id).as_deref(), Some("postgres")); + assert!(row.selected_by_default); +} + +#[test] +fn a_duplicate_of_an_unsupported_engine_stays_a_duplicate() { + let local = existing(settings(), "Orders").expect("a saved connection"); + let imported = ForeignConnection { + engine: "Vertica".to_owned(), + ..settings() + }; + let review = review(collected(vec![connection("c1", imported)], Vec::new()), vec![local]); + + let row = &review.connections()[0]; + assert!(row.duplicate.is_some()); + assert_eq!(row.connection, None); +} + +#[test] +fn missing_ssh_and_jump_host_keys_and_certificate_paths_each_warn() { + let mut ssh = tunnel("bastion", ForeignSshAuth::PrivateKey, "~/.ssh/missing_key").expect("a tunnel"); + ssh.jump_hosts = vec![hop("jump", None, "u", ForeignSshAuth::Agent, "~/.ssh/jump")]; + let imported = ForeignConnection { + ssh: Some(ssh), + ssl: Some(ForeignSsl { + mode: ForeignSslMode::VerifyIdentity, + ca_certificate_path: Some("~/ca.pem".to_owned()), + client_certificate_path: Some("~/client.pem".to_owned()), + client_key_path: Some("~/client.key".to_owned()), + }), + ..settings() + }; + let asked = std::sync::Mutex::new(Vec::new()); + let file_exists = |path: &Path| { + if let Ok(mut asked) = asked.lock() { + asked.push(path.to_path_buf()); + } + false + }; + let review = review_in( + collected(vec![connection("c1", imported)], Vec::new()), + library(Vec::new()), + &drivers(), + &file_exists, + ); + + assert_eq!( + review.connections()[0].notes, + [ + RowNote::SshKeyMissing { + path: "~/.ssh/missing_key".to_owned() + }, + RowNote::JumpHostKeyMissing { + path: "~/.ssh/jump".to_owned() + }, + RowNote::CaCertificateMissing { + path: "~/ca.pem".to_owned() + }, + RowNote::ClientCertificateMissing { + path: "~/client.pem".to_owned() + }, + RowNote::ClientKeyMissing { + path: "~/client.key".to_owned() + }, + ] + ); + let asked = asked.into_inner().expect("the paths asked about"); + assert!(asked.contains(&home().join(".ssh/missing_key"))); + assert!(asked.contains(&home().join("ca.pem"))); +} + +#[test] +fn a_duplicate_carrying_saved_queries_defaults_to_keep_existing() { + let local = existing(settings(), "Orders").expect("a saved connection"); + let review = review( + collected( + vec![connection("c1", settings()), connection("c2", named("Twin"))], + vec![query("q1", "Locks", "select 1", Some("c1"))], + ), + vec![local.clone()], + ); + + let rows = review.connections(); + assert_eq!(rows[0].saved_query_count, 1); + assert_eq!( + rows[0].resolutions, + [ + Resolution::KeepExisting(local.id), + Resolution::AddCopy, + Resolution::Replace(local.id) + ] + ); + assert_eq!(rows[1].saved_query_count, 0); + assert_eq!( + rows[1].resolutions, + [Resolution::AddCopy, Resolution::Replace(local.id)] + ); +} + +#[test] +fn exact_duplicate_queries_in_one_import_collapse_to_the_first() { + let review = review( + collected( + vec![connection("c1", settings())], + vec![ + query("q1", "Locks", "select 1", Some("c1")), + query("q2", " locks ", "select 1\n", Some("c1")), + query("q3", "Locks", "select 1", None), + query("q4", "Locks", "select 2", Some("c1")), + ], + ), + Vec::new(), + ); + + let references: Vec<&str> = review + .saved_queries() + .iter() + .map(|row| row.reference.as_str()) + .collect(); + assert_eq!(references, ["q1", "q3", "q4"]); + assert_eq!(review.connections()[0].saved_query_count, 2); +} + +#[test] +fn query_rows_carry_their_connection_and_a_derived_name_when_blank() { + let unsuggested = CollectedSavedQuery { + suggested: false, + ..query("q2", "Locks", "select 2", None) + }; + let review = review( + collected( + vec![connection("c1", named("Orders"))], + vec![query("q1", " ", "-- Active users\nselect 1", Some("c1")), unsuggested], + ), + Vec::new(), + ); + + let scoped = review.query_row("q1").expect("the scoped row"); + assert_eq!(scoped.name, "Active users"); + assert_eq!(scoped.connection.as_deref(), Some("c1")); + assert_eq!(scoped.connection_name.as_deref(), Some("Orders")); + assert!(scoped.suggested); + assert!(!scoped.too_large); + + let global = review.query_row("q2").expect("the global row"); + assert_eq!(global.connection, None); + assert_eq!(global.connection_name, None); + assert!(!global.suggested); +} + +#[test] +fn a_query_past_the_library_limit_is_too_large_and_unread_files_follow_the_rest() { + let large = "x".repeat(1024 * 1024 + 1); + let oversized = CollectedSavedQuery { + content: SavedQueryContent::Oversized { byte_count: 2_000_000 }, + suggested: false, + ..query("q3", "Dump", "", Some("c1")) + }; + let review = review( + collected( + vec![connection("c1", settings())], + vec![ + oversized, + query("q1", "Huge", &large, None), + query("q2", "Small", "select 1", None), + ], + ), + Vec::new(), + ); + + let rows = review.saved_queries(); + let references: Vec<&str> = rows.iter().map(|row| row.reference.as_str()).collect(); + assert_eq!(references, ["q1", "q2", "q3"]); + assert_eq!( + rows.iter().map(|row| row.too_large).collect::>(), + [true, false, true] + ); + assert_eq!(rows[0].byte_count, 1024 * 1024 + 1); + assert_eq!(rows[2].byte_count, 2_000_000); + assert_eq!(rows[2].connection_name.as_deref(), Some("Orders")); + assert_eq!(review.connections()[0].saved_query_count, 1); +} + +#[test] +fn the_group_path_is_trimmed_without_blank_or_invalid_names_and_clamped_to_the_depth_limit() { + let mut grouped = connection("c1", settings()); + grouped.group_path = ["Client A", " Production ", " ", "Bad\u{7}Name", "EU", "Primary"] + .map(str::to_owned) + .to_vec(); + let review = review(collected(vec![grouped], Vec::new()), Vec::new()); + + assert_eq!(review.connections()[0].group_path, ["Client A", "Production", "EU"]); +} + +#[test] +fn the_review_keeps_what_the_collection_said_about_passwords() { + let mut import = collected(vec![connection("c1", settings())], Vec::new()); + import.passwords = PasswordOutcome::LeftInKeychain; + import.app = ForeignApp::DataGrip; + let review = review(import, Vec::new()); + + assert_eq!(review.passwords(), PasswordOutcome::LeftInKeychain); + assert_eq!(review.app(), ForeignApp::DataGrip); + assert!(review.library().connections.is_empty()); +} + +#[test] +fn every_engine_maps_to_its_driver_and_a_network_port_of_zero_takes_the_default() { + let cases = [ + ("MySQL", "mysql", 3306), + ("MariaDB", "mysql", 3306), + ("PostgreSQL", "postgres", 5432), + ("SQL Server", "mssql", 1433), + ("ClickHouse", "clickhouse", 8123), + ("Cassandra", "cassandra", 9042), + ("ScyllaDB", "cassandra", 9042), + ("Redis", "redis", 6379), + ("MongoDB", "mongodb", 27017), + ]; + for (engine, driver_id, port) in cases { + let saved = mapped(ForeignConnection { + engine: engine.to_owned(), + port: 0, + ..settings() + }) + .expect("a supported engine"); + assert_eq!((saved.driver_id.as_str(), saved.port), (driver_id, port), "{engine}"); + assert_eq!(saved.host, "db.example.com"); + assert_eq!(saved.database, "orders"); + assert_eq!(saved.username, "app"); + } + let kept = mapped(ForeignConnection { + port: 6543, + ..settings() + }) + .expect("a supported engine"); + assert_eq!(kept.port, 6543); +} + +#[test] +fn a_file_engine_takes_the_expanded_path_and_no_host_port_user_or_tunnel() { + for (engine, driver_id) in [("SQLite", "sqlite"), ("DuckDB", "duckdb")] { + let row = only_row(ForeignConnection { + engine: engine.to_owned(), + database: "~/data/app.db".to_owned(), + host: "localhost".to_owned(), + ssh: tunnel("bastion", ForeignSshAuth::Agent, ""), + ssl: ssl(ForeignSslMode::Required), + ..settings() + }); + let saved = row.connection.expect("a supported engine"); + assert_eq!(saved.driver_id, driver_id); + assert_eq!(saved.database, home().join("data/app.db").display().to_string()); + assert_eq!((saved.host.as_str(), saved.port, saved.username.as_str()), ("", 0, "")); + assert_eq!(saved.ssh, None); + assert_eq!(saved.tls, TlsConfig::disabled()); + assert_eq!( + row.notes, + [ + RowNote::SshNotKept, + RowNote::TlsNotKept { + from: ForeignSslMode::Required + } + ] + ); + } +} + +#[test] +fn a_duckdb_file_comes_from_the_field_the_macos_app_keeps_it_in() { + let mut imported = ForeignConnection { + engine: "DuckDB".to_owned(), + database: String::new(), + ..settings() + }; + imported + .additional_fields + .insert("duckdbFilePath".to_owned(), "~/warehouse.duckdb".to_owned()); + let row = only_row(imported); + + assert_eq!( + row.connection.map(|saved| saved.database), + Some(home().join("warehouse.duckdb").display().to_string()) + ); + assert!(row.notes.is_empty()); +} + +#[test] +fn an_imported_connection_starts_as_a_new_one_does_in_the_app() { + let saved = mapped(settings()).expect("a supported engine"); + assert_eq!(saved.safe_mode, SafeModeLevel::Off); + assert_eq!(saved.external_access, ExternalAccess::ReadOnly); + assert_eq!(saved.ai_policy, AiPolicy::Ask); + assert_eq!(saved.query_timeout_secs, None); + assert_eq!(saved.auth_mode, AuthMode::Password); + assert_eq!(saved.options, NetworkOptions::default()); + assert_eq!((saved.tags.len(), saved.icon, saved.group_id), (0, None, None)); + assert_eq!(saved.tls, TlsConfig::disabled()); +} + +#[test] +fn each_tls_mode_maps_to_the_core_mode_and_a_change_is_noted() { + let cases = [ + (None, TlsMode::Disable, false), + (Some(ForeignSslMode::Disabled), TlsMode::Disable, false), + (Some(ForeignSslMode::Preferred), TlsMode::Require, true), + (Some(ForeignSslMode::Required), TlsMode::Require, false), + (Some(ForeignSslMode::VerifyCa), TlsMode::VerifyFull, true), + (Some(ForeignSslMode::VerifyIdentity), TlsMode::VerifyFull, false), + ]; + for (mode, expected, noted) in cases { + let row = only_row(ForeignConnection { + ssl: mode.and_then(ssl), + ..settings() + }); + let saved = row.connection.expect("a supported engine"); + assert_eq!(saved.tls.mode, expected, "{mode:?}"); + let change = mode.map(|from| RowNote::TlsModeChanged { from, to: expected }); + assert_eq!( + row.notes, + change.filter(|_| noted).into_iter().collect::>(), + "{mode:?}" + ); + } +} + +#[test] +fn a_mode_the_driver_lacks_becomes_the_nearest_it_has_and_never_weaker_when_a_stronger_exists() { + let cases = [ + (&VERIFY_ONLY, ForeignSslMode::Required, TlsMode::VerifyFull), + (&VERIFY_ONLY, ForeignSslMode::Disabled, TlsMode::VerifyFull), + (&NO_VERIFY, ForeignSslMode::VerifyIdentity, TlsMode::Require), + ]; + for (capabilities, from, to) in cases { + let row = review_with( + ForeignConnection { + ssl: ssl(from), + ..settings() + }, + ®istry(&[("postgres", capabilities)]), + ) + .connections()[0] + .clone(); + assert_eq!(row.connection.map(|saved| saved.tls.mode), Some(to)); + assert_eq!(row.notes, [RowNote::TlsModeChanged { from, to }]); + } + + let row = review_with( + ForeignConnection { + ssl: ssl(ForeignSslMode::Required), + ..settings() + }, + ®istry(&[("postgres", &NO_TLS)]), + ) + .connections()[0] + .clone(); + assert_eq!(row.connection.map(|saved| saved.tls), Some(TlsConfig::disabled())); + assert_eq!( + row.notes, + [RowNote::TlsNotKept { + from: ForeignSslMode::Required + }] + ); +} + +fn with_ca(mode: ForeignSslMode, path: &str) -> ForeignConnection { + ForeignConnection { + ssl: Some(ForeignSsl { + mode, + ca_certificate_path: Some(path.to_owned()), + client_certificate_path: None, + client_key_path: None, + }), + ..settings() + } +} + +#[test] +fn a_ca_file_is_kept_only_with_verify_full_on_a_driver_that_takes_one_as_an_absolute_path() { + let kept = mapped(with_ca(ForeignSslMode::VerifyIdentity, "~/ca.pem")).expect("a supported engine"); + assert_eq!(kept.tls.ca_file, Some(home().join("ca.pem"))); + + let cases = [ + (with_ca(ForeignSslMode::Required, "~/ca.pem"), TlsFileReason::ModeLacks), + ( + with_ca(ForeignSslMode::VerifyIdentity, "certs/ca.pem"), + TlsFileReason::RelativePath, + ), + ]; + for (imported, reason) in cases { + let path = imported + .ssl + .as_ref() + .and_then(|ssl| ssl.ca_certificate_path.clone()) + .unwrap_or_default(); + let row = only_row(imported); + assert_eq!(row.connection.and_then(|saved| saved.tls.ca_file), None); + assert_eq!(row.notes, [RowNote::CaFileNotKept { path, reason }]); + } + + let lacking = registry(&[("postgres", &VERIFY_ONLY)]); + let row = review_with(with_ca(ForeignSslMode::VerifyIdentity, "/etc/ca.pem"), &lacking).connections()[0].clone(); + assert_eq!(row.connection.and_then(|saved| saved.tls.ca_file), None); + assert_eq!( + row.notes, + [RowNote::CaFileNotKept { + path: "/etc/ca.pem".to_owned(), + reason: TlsFileReason::EngineLacks + }] + ); +} + +fn with_identity( + mode: ForeignSslMode, + certificate: Option<&str>, + key: Option<&str>, + engine: &str, +) -> ForeignConnection { + ForeignConnection { + engine: engine.to_owned(), + ssl: Some(ForeignSsl { + mode, + ca_certificate_path: None, + client_certificate_path: certificate.map(str::to_owned), + client_key_path: key.map(str::to_owned), + }), + ..settings() + } +} + +#[test] +fn a_client_identity_needs_both_files_tls_on_and_a_driver_that_takes_one() { + let kept = mapped(with_identity( + ForeignSslMode::Required, + Some("~/client.pem"), + Some("~/client.key"), + "PostgreSQL", + )) + .expect("a supported engine"); + assert_eq!( + kept.tls.client_identity, + Some(ClientIdentity { + certificate: home().join("client.pem"), + key: home().join("client.key"), + }) + ); + + let cases = [ + ( + with_identity(ForeignSslMode::Required, Some("~/client.pem"), None, "PostgreSQL"), + TlsFileReason::Incomplete, + ), + ( + with_identity(ForeignSslMode::Required, Some("~/c.pem"), Some("~/c.key"), "SQL Server"), + TlsFileReason::EngineLacks, + ), + ( + with_identity(ForeignSslMode::Disabled, Some("~/c.pem"), Some("~/c.key"), "PostgreSQL"), + TlsFileReason::ModeLacks, + ), + ( + with_identity(ForeignSslMode::Required, Some("c.pem"), Some("~/c.key"), "PostgreSQL"), + TlsFileReason::RelativePath, + ), + ]; + for (imported, reason) in cases { + let row = only_row(imported); + assert_eq!(row.connection.and_then(|saved| saved.tls.client_identity), None); + assert_eq!(row.notes, [RowNote::ClientIdentityNotKept { reason }]); + } +} + +#[test] +fn each_ssh_sign_in_maps_and_carries_only_its_own_secret() { + let credentials = Some(ForeignCredentials { + password: secret("db"), + ssh_password: secret("ssh"), + key_passphrase: secret("phrase"), + }); + let cases = [ + ( + ForeignSshAuth::Password, + "", + SavedSshAuth::Password, + SecretKind::SshPassword, + ), + ( + ForeignSshAuth::PrivateKey, + "~/.ssh/id_ed25519", + SavedSshAuth::PrivateKey { + path: Some(home().join(".ssh/id_ed25519")), + has_passphrase: true, + }, + SecretKind::SshPassphrase, + ), + ]; + for (auth, key, expected, kind) in cases { + let mut ssh = tunnel("bastion", auth, key).expect("a tunnel"); + ssh.port = Some(2222); + let mut row = connection( + "c1", + ForeignConnection { + ssh: Some(ssh), + ..settings() + }, + ); + row.credentials = credentials.clone(); + let review = review(collected(vec![row], Vec::new()), Vec::new()); + let row = &review.connections()[0]; + assert_eq!( + row.connection.as_ref().and_then(|saved| saved.ssh.clone()), + Some(SavedSshConfig { + host: "bastion".to_owned(), + port: Some(2222), + username: Some("deploy".to_owned()), + jump_hosts: Vec::new(), + auth: expected, + }) + ); + assert!(row.carries_credentials); + assert!(row.notes.is_empty()); + assert_eq!( + applied_secrets(&review), + [ + (SecretKind::DatabasePassword, "db".to_owned()), + (kind, secret_text(kind)) + ] + ); + } +} + +fn secret_text(kind: SecretKind) -> String { + match kind { + SecretKind::DatabasePassword => "db", + SecretKind::SshPassword => "ssh", + SecretKind::SshPassphrase => "phrase", + } + .to_owned() +} + +fn applied_secrets(review: &ImportReview) -> Vec<(SecretKind, String)> { + let dir = tempfile::tempdir(); + let Ok(dir) = dir else { + return Vec::new(); + }; + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", "app.tablepro.TablePro")); + let selection = ImportSelection::defaults(review); + let Ok(applied) = apply_blocking(review, &plan(review, &selection), &store) else { + return Vec::new(); + }; + applied + .connections + .iter() + .flat_map(|connection| &connection.secrets) + .map(|secret| (secret.kind, secret.secret.expose_secret().to_owned())) + .collect() +} + +#[test] +fn an_agent_tunnel_keeps_no_password_a_blank_port_and_user_are_left_out_and_a_relative_key_is_dropped() { + let mut agent = tunnel(" bastion ", ForeignSshAuth::Agent, "").expect("a tunnel"); + agent.port = Some(0); + agent.username = " ".to_owned(); + let mut row = connection( + "c1", + ForeignConnection { + ssh: Some(agent), + ..settings() + }, + ); + row.credentials = Some(ForeignCredentials { + ssh_password: secret("ssh"), + ..ForeignCredentials::default() + }); + let review = review(collected(vec![row], Vec::new()), Vec::new()); + let ssh = review.connections()[0] + .connection + .as_ref() + .and_then(|saved| saved.ssh.clone()); + assert_eq!( + ssh, + Some(SavedSshConfig { + host: "bastion".to_owned(), + port: None, + username: None, + jump_hosts: Vec::new(), + auth: SavedSshAuth::Agent, + }) + ); + assert!(!review.connections()[0].carries_credentials); + + let row = only_row(ForeignConnection { + ssh: tunnel("bastion", ForeignSshAuth::PrivateKey, "keys/id"), + ..settings() + }); + assert_eq!( + row.connection.and_then(|saved| saved.ssh).map(|ssh| ssh.auth), + Some(SavedSshAuth::PrivateKey { + path: None, + has_passphrase: false + }) + ); + assert_eq!( + row.notes, + [RowNote::SshKeyNotKept { + path: "keys/id".to_owned() + }] + ); +} + +#[test] +fn jump_hosts_become_hops_and_what_a_hop_cannot_keep_is_noted() { + let mut ssh = tunnel("bastion", ForeignSshAuth::Agent, "").expect("a tunnel"); + ssh.jump_hosts = vec![ + hop(" jump1 ", Some(2200), "ops", ForeignSshAuth::Agent, ""), + hop("fd00::1", None, "", ForeignSshAuth::PrivateKey, "~/.ssh/jump"), + hop("bad,host", None, "ops", ForeignSshAuth::Agent, ""), + hop("", None, "ops", ForeignSshAuth::Agent, ""), + hop("j3", Some(0), "me", ForeignSshAuth::Password, ""), + ]; + let row = only_row(ForeignConnection { + ssh: Some(ssh), + ..settings() + }); + + assert_eq!( + row.connection.and_then(|saved| saved.ssh).map(|ssh| ssh.jump_hosts), + Some(vec![ + "ops@jump1:2200".to_owned(), + "[fd00::1]".to_owned(), + "me@j3".to_owned() + ]) + ); + assert_eq!( + row.notes, + [ + RowNote::JumpHostKeyNotKept { + host: "fd00::1".to_owned(), + path: "~/.ssh/jump".to_owned() + }, + RowNote::JumpHostNotKept { + host: "bad,host".to_owned() + }, + RowNote::JumpHostNotKept { host: String::new() }, + RowNote::JumpHostPasswordNotKept { host: "j3".to_owned() }, + ] + ); +} + +#[test] +fn each_colour_keeps_its_name_and_gray_becomes_slate() { + let cases = [ + (ForeignColor::Red, ConnectionColor::Red), + (ForeignColor::Orange, ConnectionColor::Orange), + (ForeignColor::Yellow, ConnectionColor::Yellow), + (ForeignColor::Green, ConnectionColor::Green), + (ForeignColor::Blue, ConnectionColor::Blue), + (ForeignColor::Purple, ConnectionColor::Purple), + (ForeignColor::Pink, ConnectionColor::Pink), + (ForeignColor::Gray, ConnectionColor::Slate), + ]; + for (foreign, expected) in cases { + let saved = mapped(ForeignConnection { + color: Some(foreign), + ..settings() + }); + assert_eq!(saved.and_then(|saved| saved.color), Some(expected)); + } +} + +#[test] +fn a_database_password_is_kept_for_a_login_but_not_for_a_file_or_a_connection_that_prompts() { + let with_password = |settings: ForeignConnection| { + let mut row = connection("c1", settings); + row.credentials = password("secret"); + review(collected(vec![row], Vec::new()), Vec::new()) + }; + let login = with_password(settings()); + assert_eq!( + applied_secrets(&login), + [(SecretKind::DatabasePassword, "secret".to_owned())] + ); + + let sqlite = with_password(ForeignConnection { + engine: "SQLite".to_owned(), + database: "/srv/app.db".to_owned(), + ..settings() + }); + assert!(!sqlite.connections()[0].carries_credentials); + + let mut prompting = settings(); + prompting + .additional_fields + .insert("promptForPassword".to_owned(), "true".to_owned()); + let prompting = with_password(prompting); + assert!(!prompting.connections()[0].carries_credentials); + assert!(prompting.connections()[0].notes.is_empty()); +} + +#[test] +fn a_field_the_core_has_no_home_for_is_noted() { + let mut imported = settings(); + imported + .additional_fields + .insert("awsRegion".to_owned(), "eu-west-1".to_owned()); + imported + .additional_fields + .insert("promptForPassword".to_owned(), "true".to_owned()); + let row = only_row(imported); + + assert_eq!( + row.notes, + [RowNote::FieldsNotKept { + fields: vec!["awsRegion".to_owned()] + }] + ); +} + +#[test] +fn a_name_loses_what_cannot_be_seen_and_a_blank_one_names_the_host_or_file() { + assert_eq!( + mapped(named("Pro\u{200b}d\tA ")).map(|saved| saved.name).as_deref(), + Some("Prod A") + ); + assert_eq!( + mapped(named(" ")).map(|saved| saved.name).as_deref(), + Some("db.example.com") + ); + let file = ForeignConnection { + engine: "SQLite".to_owned(), + database: "/srv/app.db".to_owned(), + ..named("") + }; + assert_eq!(mapped(file).map(|saved| saved.name).as_deref(), Some("app.db")); + assert_eq!(only_row(named(" ")).name, " "); +} + +struct Duplicates { + review: ImportReview, + existing: Uuid, +} + +// Two rows duplicate one saved connection, a third is new. +fn duplicates(with_queries: bool) -> Option { + let local = existing(settings(), "Orders")?; + let queries = if with_queries { + vec![query("q1", "Locks", "select 1", Some("c1"))] + } else { + Vec::new() + }; + let review = review( + collected( + vec![ + connection("c1", named("First")), + connection("c2", named("Second")), + connection("c3", on_host("New", "new.example.com")), + ], + queries, + ), + vec![local.clone()], + ); + Some(Duplicates { + review, + existing: local.id, + }) +} + +#[test] +fn defaults_select_new_connections_and_leave_duplicates_unchecked() { + let Duplicates { review, .. } = duplicates(false).expect("a review"); + let selection = ImportSelection::defaults(&review); + + assert_eq!(selection.resolution("c1"), None); + assert_eq!(selection.resolution("c2"), None); + assert_eq!(selection.resolution("c3"), Some(Resolution::Add)); + assert!(selection.is_selected("c3")); + assert!(!selection.is_selected("c1")); +} + +#[test] +fn checking_a_duplicate_gives_the_row_default() { + let Duplicates { review, existing } = duplicates(true).expect("a review"); + let mut selection = ImportSelection::defaults(&review); + + selection.set_selected(true, "c1", &review); + selection.set_selected(true, "c2", &review); + + assert_eq!(selection.resolution("c1"), Some(Resolution::KeepExisting(existing))); + assert_eq!(selection.resolution("c2"), Some(Resolution::AddCopy)); +} + +#[test] +fn a_second_replace_of_the_same_connection_is_refused() { + let Duplicates { review, existing } = duplicates(false).expect("a review"); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c1", &review); + selection.set_selected(true, "c2", &review); + + assert!(selection.resolve("c1", Resolution::Replace(existing), &review)); + assert!(!selection.resolve("c2", Resolution::Replace(existing), &review)); + assert_eq!(selection.resolution("c2"), Some(Resolution::AddCopy)); + + let second = review.connection_row("c2").expect("the second row"); + assert_eq!(selection.offered_resolutions(second), [Resolution::AddCopy]); + let first = review.connection_row("c1").expect("the first row"); + assert_eq!( + selection.offered_resolutions(first), + [Resolution::AddCopy, Resolution::Replace(existing)] + ); +} + +#[test] +fn unchecking_the_row_that_holds_a_replace_frees_the_target() { + let Duplicates { review, existing } = duplicates(false).expect("a review"); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c1", &review); + selection.set_selected(true, "c2", &review); + selection.resolve("c1", Resolution::Replace(existing), &review); + + selection.set_selected(false, "c1", &review); + + assert_eq!(selection.resolution("c1"), None); + assert!(selection.resolve("c2", Resolution::Replace(existing), &review)); +} + +#[test] +fn checking_again_a_row_whose_replace_is_now_held_falls_back_to_its_default() { + let Duplicates { review, existing } = duplicates(false).expect("a review"); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c1", &review); + selection.resolve("c1", Resolution::Replace(existing), &review); + selection.set_selected(false, "c1", &review); + selection.set_selected(true, "c2", &review); + selection.resolve("c2", Resolution::Replace(existing), &review); + + selection.set_selected(true, "c1", &review); + + assert_eq!(selection.resolution("c1"), Some(Resolution::AddCopy)); + assert_eq!(selection.resolution("c2"), Some(Resolution::Replace(existing))); +} + +#[test] +fn a_choice_survives_unchecking_and_checking_the_row_again() { + let Duplicates { review, existing } = duplicates(false).expect("a review"); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c1", &review); + selection.resolve("c1", Resolution::Replace(existing), &review); + + selection.set_selected(false, "c1", &review); + selection.set_selected(true, "c1", &review); + + assert_eq!(selection.resolution("c1"), Some(Resolution::Replace(existing))); +} + +#[test] +fn a_resolution_the_row_does_not_offer_is_refused() { + let Duplicates { review, existing } = duplicates(false).expect("a review"); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c1", &review); + + let attempts = [ + selection.resolve("c1", Resolution::Add, &review), + selection.resolve("c1", Resolution::Replace(Uuid::new_v4()), &review), + selection.resolve("c1", Resolution::KeepExisting(existing), &review), + selection.resolve("c3", Resolution::AddCopy, &review), + selection.resolve("missing", Resolution::Add, &review), + ]; + + assert_eq!(attempts, [false; 5]); + assert_eq!(selection.resolution("c1"), Some(Resolution::AddCopy)); +} + +#[test] +fn a_query_override_is_stored_until_changed() { + let Duplicates { review, .. } = duplicates(true).expect("a review"); + let mut selection = ImportSelection::defaults(&review); + let row = review.query_row("q1").expect("the query row"); + assert_eq!(selection.query_override("q1"), None); + assert!(selection.wants_query(row)); + + selection.set_included(false, "q1"); + assert_eq!(selection.query_override("q1"), Some(false)); + assert!(!selection.wants_query(row)); + + selection.set_included(true, "q1"); + assert_eq!(selection.query_override("q1"), Some(true)); +} + +#[test] +fn add_writes_a_new_entry_replace_names_the_saved_id_and_keep_existing_writes_nothing() { + let replaced = existing(on_host("Old", "replace.example.com"), "Old").expect("a saved connection"); + let kept = existing(on_host("Kept", "kept.example.com"), "Kept").expect("a saved connection"); + let review = review( + collected( + vec![ + connection("c1", named("Fresh")), + connection("c2", on_host("Replacement", "replace.example.com")), + connection("c3", on_host("Kept", "kept.example.com")), + ], + vec![query("q1", "Locks", "select 1", Some("c3"))], + ), + vec![replaced.clone(), kept.clone()], + ); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c2", &review); + assert!(selection.resolve("c2", Resolution::Replace(replaced.id), &review)); + selection.set_selected(true, "c3", &review); + + let plan = plan(&review, &selection); + + let planned = plan.connections(); + assert_eq!( + planned.iter().map(|p| p.reference.as_str()).collect::>(), + ["c1", "c2"] + ); + assert_eq!(planned[0].replaces, None); + assert_eq!(planned[0].connection.name, "Fresh"); + assert_eq!(planned[1].replaces, Some(replaced.id)); + assert_eq!(planned[1].connection.id, replaced.id); + assert_eq!(planned[1].connection.name, "Replacement"); + assert_eq!(plan.kept_connections(), [("c3".to_owned(), kept.id)]); + assert_eq!(plan.queries()[0].target(), QueryTarget::Existing(kept.id)); +} + +#[test] +fn only_written_rows_bring_their_group_path() { + let kept = existing(on_host("Kept", "kept.example.com"), "Kept").expect("a saved connection"); + let grouped = |reference: &str, settings: ForeignConnection, group: &str| CollectedConnection { + group_path: vec![group.to_owned()], + ..connection(reference, settings) + }; + let review = review( + collected( + vec![ + grouped("c1", named("One"), "Team A"), + grouped("c2", on_host("Two", "two"), "Team B"), + grouped("c3", on_host("Kept", "kept.example.com"), "Team C"), + ], + vec![query("q1", "Locks", "select 1", Some("c3"))], + ), + vec![kept.clone()], + ); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(false, "c2", &review); + selection.set_selected(true, "c3", &review); + + let plan = plan(&review, &selection); + + assert_eq!(plan.connections().len(), 1); + let path: Vec<&str> = plan.connections()[0] + .group_path + .iter() + .map(|name| name.as_str()) + .collect(); + assert_eq!(path, ["Team A"]); + assert_eq!(plan.kept_connections(), [("c3".to_owned(), kept.id)]); +} + +#[test] +fn a_plan_with_nothing_to_write_is_empty() { + let review = review(collected(vec![connection("c1", settings())], Vec::new()), Vec::new()); + let mut selection = ImportSelection::defaults(&review); + assert!(!plan(&review, &selection).is_empty()); + + selection.set_selected(false, "c1", &review); + assert!(plan(&review, &selection).is_empty()); +} + +fn available(included: bool) -> Option { + Some(QueryStatus { + availability: QueryAvailability::Available, + included, + }) +} + +fn unavailable(availability: QueryAvailability) -> Option { + Some(QueryStatus { + availability, + included: false, + }) +} + +#[test] +fn a_query_whose_connection_is_not_imported_is_skipped_and_one_with_none_cannot_import() { + let review = review( + collected( + vec![connection("c1", settings())], + vec![ + query("q1", "Scoped", "select 1", Some("c1")), + query("q2", "Global", "select 2", None), + ], + ), + Vec::new(), + ); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(false, "c1", &review); + selection.set_included(true, "q1"); + selection.set_included(true, "q2"); + + let plan = plan(&review, &selection); + + assert_eq!( + plan.query_status("q1"), + unavailable(QueryAvailability::ConnectionSkipped) + ); + assert_eq!(plan.query_status("q2"), unavailable(QueryAvailability::NoConnection)); + assert!(plan.queries().is_empty()); + assert!(plan.is_empty()); +} + +#[test] +fn a_query_another_selected_row_adds_is_held_back_and_keeps_its_own_choice_for_when_that_row_goes() { + let local = existing(settings(), "Orders").expect("a saved connection"); + let review = review( + collected( + vec![connection("c1", named("One")), connection("c2", named("Two"))], + vec![ + query("q1", "Locks", "select 1", Some("c1")), + query("q2", "Locks", "select 1", Some("c2")), + ], + ), + vec![local.clone()], + ); + let select_both = |selection: &mut ImportSelection| { + selection.set_selected(true, "c1", &review); + selection.set_selected(true, "c2", &review); + }; + let planned = |selection: &ImportSelection| { + plan(&review, selection) + .queries() + .iter() + .map(|query| query.reference.clone()) + .collect::>() + }; + + let mut both = ImportSelection::defaults(&review); + select_both(&mut both); + let both_plan = plan(&review, &both); + assert_eq!( + both_plan.kept_connections(), + [("c1".to_owned(), local.id), ("c2".to_owned(), local.id)] + ); + assert_eq!( + both_plan.query_status("q2"), + unavailable(QueryAvailability::AddedByAnotherRow) + ); + assert_eq!(planned(&both), ["q1"]); + + both.set_included(false, "q1"); + assert_eq!(planned(&both), ["q2"]); + + both.set_included(false, "q2"); + assert!(planned(&both).is_empty()); +} + +#[test] +fn a_second_query_of_one_name_on_one_connection_is_held_back_since_a_save_would_overwrite_the_first() { + let review = review( + collected( + vec![connection("c1", settings())], + vec![ + query("q1", "Report", "select 1", Some("c1")), + query("q2", "Report", "select 2", Some("c1")), + query("q3", "report", "select 3", Some("c1")), + ], + ), + Vec::new(), + ); + let plan = plan(&review, &ImportSelection::defaults(&review)); + + assert_eq!(plan.query_status("q1"), available(true)); + assert_eq!( + plan.query_status("q2"), + unavailable(QueryAvailability::AddedByAnotherRow) + ); + assert_eq!(plan.query_status("q3"), available(true)); +} + +#[test] +fn replace_scopes_queries_to_the_replaced_connection_and_as_copy_to_the_new_one() { + let Duplicates { review, existing } = duplicates(true).expect("a review"); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c1", &review); + + assert!(selection.resolve("c1", Resolution::Replace(existing), &review)); + assert_eq!( + plan(&review, &selection).queries()[0].target(), + QueryTarget::Existing(existing) + ); + + assert!(selection.resolve("c1", Resolution::AddCopy, &review)); + assert_eq!( + plan(&review, &selection).queries()[0].target(), + QueryTarget::Imported("c1") + ); +} + +#[test] +fn an_unchecked_query_reserves_nothing_for_the_rows_after_it() { + let review = review( + collected( + vec![connection("c1", settings())], + vec![ + query("q1", "Report", "select 1", Some("c1")), + query("q2", "Report", "select 2", Some("c1")), + ], + ), + Vec::new(), + ); + let mut selection = ImportSelection::defaults(&review); + selection.set_included(false, "q1"); + + let plan = plan(&review, &selection); + + assert_eq!(plan.query_status("q1"), available(false)); + assert_eq!(plan.query_status("q2"), available(true)); + assert_eq!(plan.queries().len(), 1); +} + +#[test] +fn an_unsuggested_query_starts_unchecked_and_can_be_checked() { + let unsuggested = CollectedSavedQuery { + suggested: false, + ..query("q1", "Script-3", "select 1", Some("c1")) + }; + let review = review( + collected(vec![connection("c1", settings())], vec![unsuggested]), + Vec::new(), + ); + let mut selection = ImportSelection::defaults(&review); + + assert_eq!(plan(&review, &selection).query_status("q1"), available(false)); + selection.set_included(true, "q1"); + assert_eq!(plan(&review, &selection).queries().len(), 1); +} + +#[test] +fn a_too_large_query_or_too_long_name_is_never_included_even_when_checked() { + let oversized = CollectedSavedQuery { + content: SavedQueryContent::Oversized { byte_count: 2_000_000 }, + ..query("q2", "Dump", "", Some("c1")) + }; + let review = review( + collected( + vec![connection("c1", settings())], + vec![ + query("q1", "Huge", &"x".repeat(1024 * 1024 + 1), Some("c1")), + oversized, + query("q3", &"n".repeat(201), "select 1", Some("c1")), + query("q4", &"n".repeat(200), "select 1", Some("c1")), + ], + ), + Vec::new(), + ); + let mut selection = ImportSelection::defaults(&review); + for reference in ["q1", "q2", "q3", "q4"] { + selection.set_included(true, reference); + } + + let plan = plan(&review, &selection); + + assert_eq!(plan.query_status("q1"), unavailable(QueryAvailability::TooLarge)); + assert_eq!(plan.query_status("q2"), unavailable(QueryAvailability::TooLarge)); + assert_eq!(plan.query_status("q3"), unavailable(QueryAvailability::NameTooLong)); + assert_eq!(plan.query_status("q4"), available(true)); + assert_eq!(plan.query_statuses().len(), 4); +} + +fn store_in(dir: &Path) -> ConnectionStore { + ConnectionStore::new(&StoragePaths::under(dir, "tablepro", "app.tablepro.TablePro")) +} + +fn credentialed(reference: &str, settings: ForeignConnection, secret: &str) -> CollectedConnection { + CollectedConnection { + credentials: password(secret), + ..connection(reference, settings) + } +} + +#[test] +fn the_write_adds_groups_copies_and_replacements_and_hands_back_secrets_in_plan_order() { + let dir = tempfile::tempdir().expect("a temp dir"); + let store = store_in(dir.path()); + let held = existing(on_host("Orders", "dup.example.com"), "Orders").expect("a saved connection"); + store.upsert_blocking(held.clone()).expect("a save"); + + let mut grouped = credentialed("c1", named("Add"), "one"); + grouped.group_path = vec!["Work".to_owned(), "Staging".to_owned()]; + let review = review_in( + collected( + vec![ + grouped, + credentialed("c2", on_host("Orders", "dup.example.com"), "two"), + credentialed("c3", on_host("Replace", "dup.example.com"), "three"), + credentialed("c4", on_host("Unselected", "four"), "four"), + ], + Vec::new(), + ), + store.list_blocking().expect("the list"), + &drivers(), + &|_| true, + ); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c2", &review); + selection.set_selected(true, "c3", &review); + assert!(selection.resolve("c3", Resolution::Replace(held.id), &review)); + selection.set_selected(false, "c4", &review); + + let applied = apply_blocking(&review, &plan(&review, &selection), &store).expect("the write"); + + let summary: Vec<(&str, bool, String)> = applied + .connections + .iter() + .map(|applied| { + let secret = applied + .secrets + .iter() + .map(|s| s.secret.expose_secret().to_owned()) + .collect(); + (applied.reference.as_str(), applied.replaced, secret) + }) + .collect(); + assert_eq!( + summary, + [ + ("c1", false, "one".to_owned()), + ("c2", false, "two".to_owned()), + ("c3", true, "three".to_owned()) + ] + ); + assert_eq!(applied.connections[2].connection.id, held.id); + assert_eq!(applied.connections[1].connection.name, "Orders (imported)"); + assert!( + applied + .connections + .iter() + .all(|applied| applied.secrets[0].kind == SecretKind::DatabasePassword) + ); + + let after = store.list_blocking().expect("the list"); + assert_eq!(after.connections.len(), 3); + let leaf = applied.connections[0].connection.group_id.expect("a group"); + assert_eq!(after.group_tree().label(leaf).as_deref(), Some("Work / Staging")); +} + +#[test] +fn a_library_changed_since_the_review_is_refused() { + let dir = tempfile::tempdir().expect("a temp dir"); + let store = store_in(dir.path()); + let review = review_in( + collected(vec![connection("c1", settings())], Vec::new()), + store.list_blocking().expect("the list"), + &drivers(), + &|_| true, + ); + let meanwhile = existing(on_host("Meanwhile", "elsewhere"), "Meanwhile").expect("a saved connection"); + store.upsert_blocking(meanwhile).expect("a save"); + + let refused = apply_blocking(&review, &plan(&review, &ImportSelection::defaults(&review)), &store); + + assert!(matches!(refused, Err(StorageError::ImportOutdated))); + assert_eq!(store.list_blocking().expect("the list").connections.len(), 1); +} + +#[test] +fn queries_land_on_their_connections_and_a_name_the_connection_holds_is_left_alone() { + let dir = tempfile::tempdir().expect("a temp dir"); + let store = store_in(dir.path()); + let held = existing(on_host("Kept", "kept.example.com"), "Kept").expect("a saved connection"); + store.upsert_blocking(held.clone()).expect("a save"); + let review = review_in( + collected( + vec![ + connection("c1", named("Fresh")), + connection("c2", on_host("Kept", "kept.example.com")), + ], + vec![ + query("q1", " Report ", "select 1", Some("c1")), + query("q2", "Locks", "select 2", Some("c2")), + query("q3", "Sizes", "select 3", Some("c2")), + ], + ), + store.list_blocking().expect("the list"), + &drivers(), + &|_| true, + ); + let mut selection = ImportSelection::defaults(&review); + selection.set_selected(true, "c2", &review); + assert_eq!(selection.resolution("c2"), Some(Resolution::KeepExisting(held.id))); + + let applied = apply_blocking(&review, &plan(&review, &selection), &store).expect("the write"); + assert_eq!(applied.connections.len(), 1); + let fresh = applied.connections[0].connection.id; + let targets: Vec<(&str, &str, Uuid, &str)> = applied + .queries + .iter() + .map(|imported| { + let NewSavedQuery { + name, + connection_id, + connection_name, + .. + } = &imported.query; + ( + imported.reference.as_str(), + name.as_str(), + *connection_id, + connection_name.as_str(), + ) + }) + .collect(); + assert_eq!( + targets, + [ + ("q1", "Report", fresh, "Fresh"), + ("q2", "Locks", held.id, "Kept"), + ("q3", "Sizes", held.id, "Kept") + ] + ); + + let outcome = sqlx::test_block_on(async { + let paths = StoragePaths::under(dir.path(), "tablepro", "app.tablepro.TablePro"); + let saved_queries = QueryHistory::open(&paths).await.expect("the history").saved_queries(); + saved_queries + .save(NewSavedQuery { + name: "Locks".to_owned(), + query: "select 'mine'".to_owned(), + connection_id: held.id, + connection_name: "Kept".to_owned(), + }) + .await + .expect("a saved query"); + let outcome = save_queries(&applied, &saved_queries).await; + let mine = saved_queries.list(Some(held.id)).await.expect("the queries"); + (outcome, mine) + }); + + let (outcome, mine) = outcome; + assert_eq!((outcome.added, outcome.already_saved, outcome.failed), (2, 1, 0)); + let locks = mine + .iter() + .find(|query| query.name == "Locks") + .expect("the user's query"); + assert_eq!(locks.query, "select 'mine'"); +} + +#[test] +fn a_mongo_seed_list_and_auth_database_become_the_cores_options_and_are_dropped_where_no_driver_reads_them() { + let seeded = ForeignConnection { + engine: "MongoDB".to_owned(), + host: "cluster0.example.net".to_owned(), + port: 27017, + additional_fields: [ + ("mongoUseSrv".to_owned(), "true".to_owned()), + ("mongoAuthSource".to_owned(), "admin".to_owned()), + ] + .into(), + ..settings() + }; + let row = only_row(seeded.clone()); + let options = row.connection.expect("a supported engine").options; + assert!(options.dns_seed_list); + assert_eq!(options.auth_database.as_deref(), Some("admin")); + assert!(row.notes.is_empty(), "{:?}", row.notes); + + let elsewhere = only_row(ForeignConnection { + engine: "PostgreSQL".to_owned(), + ..seeded + }); + assert!(!elsewhere.connection.expect("a supported engine").options.dns_seed_list); + assert_eq!( + elsewhere.notes, + [RowNote::FieldsNotKept { + fields: vec!["mongoAuthSource".to_owned(), "mongoUseSrv".to_owned()] + }] + ); +} + +#[test] +fn tls_settings_the_importer_could_not_read_are_noted_and_leave_the_row_unchecked() { + let unread = ForeignConnection { + additional_fields: [("tlsSettingsUnread".to_owned(), "true".to_owned())].into(), + ..settings() + }; + let row = only_row(unread); + assert_eq!(row.notes, [RowNote::TlsSettingsNotRead]); + assert!(!row.selected_by_default); + assert!(row.connection.is_some(), "the row can still be checked by hand"); +} diff --git a/core/crates/foreign-import/tests/sequel_ace.rs b/core/crates/foreign-import/tests/sequel_ace.rs new file mode 100644 index 0000000000..fd7d1e4522 --- /dev/null +++ b/core/crates/foreign-import/tests/sequel_ace.rs @@ -0,0 +1,621 @@ +#![expect( + clippy::expect_used, + reason = "the fixture helpers fail a test the way the test functions themselves do" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use secrecy::{ExposeSecret, SecretString}; +use tablepro_foreign_import::{ + CollectedConnection, CollectedImport, ForeignApp, ForeignAppDirs, ForeignColor, ForeignHost, ForeignImportError, + ForeignImporter, ForeignKeychain, ForeignPlatform, ForeignSshAuth, ForeignSslMode, ImportRequest, Inventory, + KeychainRead, PasswordOutcome, PasswordSource, SavedQueryContent, SavedQuerySupport, +}; +use tokio_util::sync::CancellationToken; + +const LIMIT: u64 = 64 * 1024; + +fn home(scenario: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("sequel_ace") + .join(scenario) + .join("macos") +} + +#[derive(Default)] +struct Keychain { + passwords: HashMap<(String, String), String>, + denied: Option<(String, String)>, + asked: Mutex>, +} + +impl Keychain { + fn with(passwords: &[(&str, &str, &str)]) -> Self { + Self { + passwords: passwords + .iter() + .map(|(service, account, password)| { + (((*service).to_owned(), (*account).to_owned()), (*password).to_owned()) + }) + .collect(), + ..Self::default() + } + } + + fn asked(&self) -> Vec<(String, String)> { + self.asked.lock().expect("the lock").clone() + } +} + +impl ForeignKeychain for Keychain { + fn read_password(&self, service: &str, account: Option<&str>) -> KeychainRead { + let key = (service.to_owned(), account.unwrap_or_default().to_owned()); + self.asked.lock().expect("the lock").push(key.clone()); + if self.denied.as_ref() == Some(&key) { + return KeychainRead::Denied; + } + match self.passwords.get(&key) { + Some(password) => KeychainRead::Found(SecretString::from(password.clone())), + None => KeychainRead::NotFound, + } + } +} + +#[derive(Default)] +struct Host { + installed: Option, + keychain: Option>, +} + +impl ForeignHost for Host { + fn installed(&self, _app: ForeignApp) -> Option { + self.installed + } + + fn keychain(&self) -> Option> { + self.keychain + .clone() + .map(|keychain| keychain as Arc) + } +} + +fn importer_on(platform: ForeignPlatform, home: &Path, host: Host) -> ForeignImporter { + ForeignImporter::new( + ForeignApp::SequelAce, + ForeignAppDirs::conventional(platform, home), + Arc::new(host), + ) +} + +fn importer(home: &Path, host: Host) -> ForeignImporter { + importer_on(ForeignPlatform::MacOs, home, host) +} + +fn request(include_passwords: bool, include_saved_queries: bool) -> ImportRequest { + ImportRequest { + include_passwords, + include_saved_queries, + saved_query_byte_limit: LIMIT, + } +} + +fn collect_with(home: &Path, host: Host, request: &ImportRequest) -> Result { + importer(home, host).collect_blocking(request, &CancellationToken::new()) +} + +fn collect(scenario: &str, request: &ImportRequest) -> CollectedImport { + collect_with(&home(scenario), Host::default(), request).expect("an import") +} + +fn connection<'a>(import: &'a CollectedImport, name: &str) -> &'a CollectedConnection { + import + .connections + .iter() + .find(|connection| connection.settings.name == name) + .expect("the connection") +} + +fn password(connection: &CollectedConnection) -> Option<&str> { + connection + .credentials + .as_ref() + .and_then(|credentials| credentials.password.as_ref()) + .map(ExposeSecret::expose_secret) +} + +fn ssh_password(connection: &CollectedConnection) -> Option<&str> { + connection + .credentials + .as_ref() + .and_then(|credentials| credentials.ssh_password.as_ref()) + .map(ExposeSecret::expose_secret) +} + +fn asked(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(service, account)| ((*service).to_owned(), (*account).to_owned())) + .collect() +} + +#[test] +fn the_app_is_known_by_its_id_and_reads_passwords_from_the_keychain() { + let app = ForeignApp::SequelAce; + assert_eq!(app.id(), "sequelace"); + assert_eq!(app.display_name(), "Sequel Ace"); + assert_eq!(ForeignApp::from_id("sequelace"), Some(app)); + assert_eq!(app.password_source(), PasswordSource::Keychain); + assert_eq!(app.saved_query_support(), SavedQuerySupport::GlobalFolder); + assert_eq!(app.picked_file_extensions(), None); + assert!(app.runs_on(ForeignPlatform::MacOs)); + assert!(!app.runs_on(ForeignPlatform::Linux)); + assert!(!app.runs_on(ForeignPlatform::Windows)); +} + +#[test] +fn is_available_when_the_app_is_installed() { + let empty = tempfile::tempdir().expect("a temp dir"); + let host = Host { + installed: Some(true), + ..Host::default() + }; + assert!(importer(empty.path(), host).is_available_blocking()); +} + +#[test] +fn is_not_available_when_the_app_is_missing_even_with_a_favorites_file() { + let host = Host { + installed: Some(false), + ..Host::default() + }; + assert!(!importer(&home("full"), host).is_available_blocking()); +} + +#[test] +fn the_favorites_file_decides_when_the_platform_cannot_tell_if_the_app_is_installed() { + let empty = tempfile::tempdir().expect("a temp dir"); + assert!(importer(&home("full"), Host::default()).is_available_blocking()); + assert!(!importer(empty.path(), Host::default()).is_available_blocking()); +} + +#[test] +fn nothing_is_read_off_macos() { + let linux = importer_on(ForeignPlatform::Linux, &home("full"), Host::default()); + assert!(!linux.is_available_blocking()); + assert_eq!(linux.inventory_blocking(), Inventory::default()); + let outcome = linux.collect_blocking(&request(false, true), &CancellationToken::new()); + assert!(matches!( + outcome, + Err(ForeignImportError::FileNotFound { + app: ForeignApp::SequelAce + }) + )); +} + +#[test] +fn inventory_counts_connections_in_every_group_and_query_favorites_with_text() { + let inventory = importer(&home("full"), Host::default()).inventory_blocking(); + assert_eq!( + inventory, + Inventory { + connections: 7, + saved_queries: 2, + } + ); +} + +#[test] +fn inventory_is_empty_when_the_files_are_missing() { + let empty = tempfile::tempdir().expect("a temp dir"); + assert_eq!( + importer(empty.path(), Host::default()).inventory_blocking(), + Inventory::default() + ); +} + +#[test] +fn collect_reads_every_connection_as_mysql() { + let import = collect("full", &request(false, false)); + assert_eq!(import.app, ForeignApp::SequelAce); + assert_eq!(import.connections.len(), 7); + assert!( + import + .connections + .iter() + .all(|connection| connection.settings.engine == "MySQL") + ); +} + +#[test] +fn an_empty_group_adds_nothing() { + let import = collect("full", &request(false, false)); + let names: Vec<&str> = import + .connections + .iter() + .map(|connection| connection.settings.name.as_str()) + .collect(); + assert_eq!( + names, + [ + "Prod Main", + "Prod Replica", + "Local", + "Staging EU", + "Socket DB", + "Unnamed Member", + "Key By Name" + ] + ); +} + +#[test] +fn connections_are_keyed_by_their_sequel_ace_id() { + let import = collect("full", &request(false, false)); + let references: Vec<&str> = import + .connections + .iter() + .map(|connection| connection.reference.as_str()) + .collect(); + assert_eq!( + references, + ["1", "2", "42", "staging-eu", "-5034467137374584238", "4", "5"] + ); +} + +#[test] +fn groups_keep_their_full_path_and_a_nameless_group_is_untitled() { + let import = collect("full", &request(false, false)); + let paths: Vec> = import + .connections + .iter() + .map(|connection| connection.group_path.clone()) + .collect(); + let expected: [&[&str]; 7] = [ + &["Production"], + &["Production"], + &[], + &["Staging", "EU"], + &[], + &["Untitled Group"], + &["Untitled Group"], + ]; + assert_eq!( + paths, + expected.map(|path| path.iter().map(|name| (*name).to_owned()).collect::>()) + ); +} + +#[test] +fn an_ssh_connection_reads_its_tunnel_with_a_key() { + let import = collect("full", &request(false, false)); + let replica = connection(&import, "Prod Replica"); + let ssh = replica.settings.ssh.as_ref().expect("a tunnel"); + assert_eq!(ssh.host, "bastion.example.com"); + assert_eq!(ssh.port, Some(2222)); + assert_eq!(ssh.username, "deploy"); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, "~/.ssh/id_ed25519"); + assert!(ssh.jump_hosts.is_empty()); + assert_eq!(replica.settings.host, "10.0.0.5"); + assert_eq!(replica.settings.port, 3307); + assert_eq!(replica.settings.username, "replica"); + assert_eq!(replica.settings.database, "shop"); +} + +#[test] +fn an_ssh_tunnel_without_a_key_uses_password_auth() { + let import = collect("full", &request(false, false)); + let ssh = connection(&import, "Staging EU") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.auth, ForeignSshAuth::Password); + assert_eq!(ssh.private_key_path, ""); + assert_eq!(ssh.username, "admin"); +} + +#[test] +fn an_ssh_port_reads_from_a_number_or_a_string() { + let import = collect("full", &request(false, false)); + let port = |name: &str| connection(&import, name).settings.ssh.as_ref().expect("a tunnel").port; + assert_eq!(port("Prod Replica"), Some(2222)); + assert_eq!(port("Staging EU"), Some(3333)); + assert_eq!(port("Key By Name"), None); +} + +#[test] +fn a_bare_key_name_is_read_from_the_ssh_folder() { + let import = collect("full", &request(false, false)); + let ssh = connection(&import, "Key By Name") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, "~/.ssh/id_rsa"); +} + +#[test] +fn a_tcp_or_socket_connection_has_no_tunnel() { + let import = collect("full", &request(false, false)); + assert!(connection(&import, "Local").settings.ssh.is_none()); + let socket = connection(&import, "Socket DB"); + assert!(socket.settings.ssh.is_none()); + assert_eq!(socket.settings.engine, "MySQL"); +} + +#[test] +fn ssl_is_required_with_its_certificate_paths() { + let import = collect("full", &request(false, false)); + let ssl = connection(&import, "Prod Main").settings.ssl.as_ref().expect("ssl"); + assert_eq!(ssl.mode, ForeignSslMode::Required); + assert_eq!(ssl.ca_certificate_path.as_deref(), Some("/path/to/ca.pem")); + assert_eq!(ssl.client_certificate_path.as_deref(), Some("/path/to/client-cert.pem")); + assert_eq!(ssl.client_key_path.as_deref(), Some("/path/to/client-key.pem")); +} + +#[test] +fn a_boolean_use_ssl_turns_ssl_on() { + let import = collect("full", &request(false, false)); + let ssl = connection(&import, "Staging EU").settings.ssl.as_ref().expect("ssl"); + assert_eq!(ssl.mode, ForeignSslMode::Required); + assert_eq!(ssl.ca_certificate_path.as_deref(), Some("")); +} + +#[test] +fn there_is_no_ssl_when_use_ssl_is_zero() { + let import = collect("full", &request(false, false)); + assert!(connection(&import, "Local").settings.ssl.is_none()); +} + +#[test] +fn an_empty_port_falls_back_to_the_mysql_default() { + let import = collect("full", &request(false, false)); + let local = connection(&import, "Local"); + assert_eq!(local.settings.port, 3306); + assert_eq!(local.settings.host, "127.0.0.1"); + assert_eq!(local.settings.database, ""); +} + +#[test] +fn a_colour_index_maps_to_a_colour() { + let import = collect("colors", &request(false, false)); + let colors: Vec> = import + .connections + .iter() + .map(|connection| connection.settings.color) + .collect(); + assert_eq!( + colors, + [ + Some(ForeignColor::Red), + Some(ForeignColor::Orange), + Some(ForeignColor::Yellow), + Some(ForeignColor::Green), + Some(ForeignColor::Blue), + Some(ForeignColor::Purple), + Some(ForeignColor::Pink), + Some(ForeignColor::Gray), + None, + None, + ] + ); +} + +#[test] +fn empty_favorites_have_nothing_to_import() { + let outcome = collect_with(&home("empty"), Host::default(), &request(false, false)); + assert!(matches!(outcome, Err(ForeignImportError::NothingFound))); +} + +#[test] +fn a_missing_favorites_file_is_not_found() { + let empty = tempfile::tempdir().expect("a temp dir"); + let outcome = collect_with(empty.path(), Host::default(), &request(false, false)); + assert!(matches!( + outcome, + Err(ForeignImportError::FileNotFound { + app: ForeignApp::SequelAce + }) + )); +} + +#[test] +fn a_favorites_file_that_is_not_a_property_list_fails_to_parse() { + let outcome = collect_with(&home("corrupt"), Host::default(), &request(false, false)); + assert!(matches!(outcome, Err(ForeignImportError::Parse { .. }))); +} + +#[test] +fn favorites_without_a_root_are_an_unsupported_format() { + let outcome = collect_with(&home("no-root"), Host::default(), &request(false, false)); + assert!(matches!(outcome, Err(ForeignImportError::UnsupportedFormat { .. }))); +} + +#[test] +fn without_passwords_no_keychain_item_is_read() { + let keychain = Arc::new(Keychain::with(&[( + "Sequel Ace : Prod Main (1)", + "root@prod.example.com/shop", + "x", + )])); + let host = Host { + installed: None, + keychain: Some(Arc::clone(&keychain)), + }; + let import = collect_with(&home("full"), host, &request(false, false)).expect("an import"); + assert_eq!(import.passwords, PasswordOutcome::NotRequested); + assert!( + import + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); + assert!(keychain.asked().is_empty()); +} + +#[test] +fn passwords_are_read_under_the_apps_keychain_service_and_account() { + let keychain = Arc::new(Keychain::with(&[ + ( + "Sequel Ace : Prod Main (1)", + "root@prod.example.com/shop", + "prod-secret", + ), + ( + "Sequel Ace SSHTunnel : Prod Replica (2)", + "deploy@bastion.example.com", + "tunnel-secret", + ), + ( + "Sequel Ace : Socket DB (-5034467137374584238)", + "root@localhost/", + "socket-secret", + ), + ])); + let host = Host { + installed: None, + keychain: Some(Arc::clone(&keychain)), + }; + let import = collect_with(&home("full"), host, &request(true, false)).expect("an import"); + + assert_eq!(import.passwords, PasswordOutcome::Read); + assert_eq!(password(connection(&import, "Prod Main")), Some("prod-secret")); + let replica = connection(&import, "Prod Replica"); + assert_eq!(password(replica), None); + assert_eq!(ssh_password(replica), Some("tunnel-secret")); + assert_eq!(password(connection(&import, "Socket DB")), Some("socket-secret")); + assert!(connection(&import, "Local").credentials.is_none()); + assert_eq!( + keychain.asked(), + asked(&[ + ("Sequel Ace : Prod Main (1)", "root@prod.example.com/shop"), + ("Sequel Ace : Prod Replica (2)", "replica@10.0.0.5/shop"), + ("Sequel Ace SSHTunnel : Prod Replica (2)", "deploy@bastion.example.com"), + ("Sequel Ace : Local (42)", "root@127.0.0.1/"), + ("Sequel Ace : Staging EU (staging-eu)", "app@staging.internal/staging"), + ( + "Sequel Ace SSHTunnel : Staging EU (staging-eu)", + "admin@bastion.eu.example.com" + ), + ("Sequel Ace : Socket DB (-5034467137374584238)", "root@localhost/"), + ("Sequel Ace : Unnamed Member (4)", "root@member.example.com/"), + ("Sequel Ace : Key By Name (5)", "root@keyed.internal/"), + ("Sequel Ace SSHTunnel : Key By Name (5)", "ops@jump.example.com"), + ]) + ); +} + +#[test] +fn a_refused_keychain_prompt_stops_every_later_read() { + let mut keychain = Keychain::with(&[( + "Sequel Ace : Prod Main (1)", + "root@prod.example.com/shop", + "prod-secret", + )]); + keychain.denied = Some(( + "Sequel Ace : Prod Replica (2)".to_owned(), + "replica@10.0.0.5/shop".to_owned(), + )); + let keychain = Arc::new(keychain); + let host = Host { + installed: None, + keychain: Some(Arc::clone(&keychain)), + }; + let import = collect_with(&home("full"), host, &request(true, false)).expect("an import"); + + assert_eq!(import.passwords, PasswordOutcome::Stopped); + assert_eq!(password(connection(&import, "Prod Main")), Some("prod-secret")); + assert_eq!( + import + .connections + .iter() + .filter(|connection| connection.credentials.is_some()) + .count(), + 1 + ); + assert_eq!( + keychain.asked(), + asked(&[ + ("Sequel Ace : Prod Main (1)", "root@prod.example.com/shop"), + ("Sequel Ace : Prod Replica (2)", "replica@10.0.0.5/shop"), + ]) + ); +} + +#[test] +fn passwords_stay_in_a_keychain_the_platform_cannot_read() { + let import = collect("full", &request(true, false)); + assert_eq!(import.passwords, PasswordOutcome::LeftInKeychain); + assert!( + import + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); +} + +#[test] +fn query_favorites_are_global_queries_with_placeholders_verbatim_and_the_tab_trigger_as_keyword() { + let import = collect("full", &request(false, true)); + let names: Vec<&str> = import.saved_queries.iter().map(|query| query.name.as_str()).collect(); + assert_eq!(names, ["Recent orders", "Plain"]); + + let recent = &import.saved_queries[0]; + assert_eq!( + recent.content, + SavedQueryContent::Text("SELECT * FROM orders WHERE id > ${1:x};".to_owned()) + ); + assert_eq!(recent.keyword.as_deref(), Some("ro")); + assert_eq!(recent.connection, None); + assert_eq!(recent.folder_path, ["Sequel Ace"]); + assert!(recent.suggested); + assert_eq!(import.saved_queries[1].keyword, None); +} + +#[test] +fn a_query_favorite_over_the_size_limit_is_listed_by_size() { + let small = ImportRequest { + saved_query_byte_limit: 10, + ..request(false, true) + }; + let import = collect("full", &small); + let recent = &import.saved_queries[0]; + assert_eq!( + recent.content, + SavedQueryContent::Oversized { + byte_count: "SELECT * FROM orders WHERE id > ${1:x};".len() as u64 + } + ); + assert!(!recent.suggested); + assert_eq!( + import.saved_queries[1].content, + SavedQueryContent::Text("SELECT 1;".to_owned()) + ); +} + +#[test] +fn query_favorites_are_read_only_when_saved_queries_are_requested() { + let import = collect("full", &request(false, false)); + assert!(import.saved_queries.is_empty()); +} + +#[test] +fn a_missing_preferences_file_means_no_query_favorites() { + let import = collect("colors", &request(false, true)); + assert_eq!(import.connections.len(), 10); + assert!(import.saved_queries.is_empty()); +} + +#[test] +fn reading_stops_when_cancelled() { + let cancel = CancellationToken::new(); + cancel.cancel(); + let outcome = importer(&home("full"), Host::default()).collect_blocking(&request(false, true), &cancel); + assert!(matches!(outcome, Err(ForeignImportError::Cancelled))); +} diff --git a/core/crates/foreign-import/tests/tableplus.rs b/core/crates/foreign-import/tests/tableplus.rs new file mode 100644 index 0000000000..d110fd0619 --- /dev/null +++ b/core/crates/foreign-import/tests/tableplus.rs @@ -0,0 +1,1049 @@ +#![expect( + clippy::expect_used, + clippy::panic, + reason = "the fixture helpers fail a test the way the test functions themselves do" +)] + +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use secrecy::{ExposeSecret, SecretString}; +use tablepro_foreign_import::{ + CollectedConnection, CollectedImport, CollectedSavedQuery, ForeignApp, ForeignAppDirs, ForeignColor, ForeignHost, + ForeignImportError, ForeignImporter, ForeignKeychain, ForeignPlatform, ForeignSshAuth, ForeignSslMode, + ImportRequest, Inventory, KeychainRead, PasswordOutcome, PasswordSource, SavedQueryContent, SavedQuerySupport, +}; +use tokio_util::sync::CancellationToken; + +const LIMIT: u64 = 900_000; +const SERVICE: &str = "com.tableplus.TablePlus"; +const PLATFORMS: [ForeignPlatform; 3] = [ForeignPlatform::MacOs, ForeignPlatform::Windows, ForeignPlatform::Linux]; + +const CONNECTIONS_ONLY: ImportRequest = ImportRequest { + include_passwords: false, + include_saved_queries: false, + saved_query_byte_limit: LIMIT, +}; +const WITH_PASSWORDS: ImportRequest = ImportRequest { + include_passwords: true, + ..CONNECTIONS_ONLY +}; +const WITH_SAVED_QUERIES: ImportRequest = ImportRequest { + include_saved_queries: true, + ..CONNECTIONS_ONLY +}; + +#[derive(Clone, Copy)] +enum Answer { + Found(&'static str), + Denied, +} + +#[derive(Default)] +struct Keychain { + answers: HashMap, + calls: Mutex>, +} + +impl Keychain { + fn answering(answers: &[(&str, Answer)]) -> Arc { + Arc::new(Self { + answers: answers + .iter() + .map(|(account, answer)| ((*account).to_owned(), *answer)) + .collect(), + calls: Mutex::new(Vec::new()), + }) + } + + fn calls(&self) -> Vec<(String, String)> { + self.calls.lock().expect("the calls").clone() + } + + fn accounts_of(&self, connection_id: &str) -> BTreeSet { + let prefix = format!("{connection_id}_"); + self.calls() + .into_iter() + .map(|(_, account)| account) + .filter(|account| account.starts_with(&prefix)) + .collect() + } +} + +impl ForeignKeychain for Keychain { + fn read_password(&self, service: &str, account: Option<&str>) -> KeychainRead { + let account = account.unwrap_or_default().to_owned(); + let answer = self.answers.get(&account).copied(); + self.calls + .lock() + .expect("the calls") + .push((service.to_owned(), account)); + match answer { + Some(Answer::Found(secret)) => KeychainRead::Found(SecretString::from(secret)), + Some(Answer::Denied) => KeychainRead::Denied, + None => KeychainRead::NotFound, + } + } +} + +struct Host { + keychain: Option>, +} + +impl ForeignHost for Host { + fn keychain(&self) -> Option> { + self.keychain + .clone() + .map(|keychain| keychain as Arc) + } +} + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/tableplus") + .join(name) +} + +fn copy_tree(from: &Path, to: &Path) { + fs::create_dir_all(to).expect("a folder"); + for child in fs::read_dir(from).expect("a fixture folder") { + let child = child.expect("a fixture entry"); + let target = to.join(child.file_name()); + if child.file_type().expect("a file type").is_dir() { + copy_tree(&child.path(), &target); + } else { + fs::copy(child.path(), target).expect("a copy"); + } + } +} + +fn writable(name: &str) -> tempfile::TempDir { + let home = tempfile::tempdir().expect("a temp dir"); + copy_tree(&fixture(name), home.path()); + home +} + +fn importer(platform: ForeignPlatform, home: &Path, keychain: Option>) -> ForeignImporter { + ForeignImporter::new( + ForeignApp::TablePlus, + ForeignAppDirs::conventional(platform, home), + Arc::new(Host { keychain }), + ) +} + +fn try_collect( + platform: ForeignPlatform, + home: &Path, + request: ImportRequest, + keychain: Option>, +) -> Result { + importer(platform, home, keychain).collect_blocking(&request, &CancellationToken::new()) +} + +fn collect(platform: ForeignPlatform, home: &Path, request: ImportRequest) -> CollectedImport { + try_collect(platform, home, request, None).expect("an import") +} + +fn macos(request: ImportRequest) -> CollectedImport { + collect(ForeignPlatform::MacOs, &fixture("macos"), request) +} + +fn macos_with_keychain(keychain: &Arc) -> CollectedImport { + try_collect( + ForeignPlatform::MacOs, + &fixture("macos"), + WITH_PASSWORDS, + Some(Arc::clone(keychain)), + ) + .expect("an import") +} + +fn connection<'a>(collected: &'a CollectedImport, name: &str) -> &'a CollectedConnection { + collected + .connections + .iter() + .find(|connection| connection.settings.name == name) + .unwrap_or_else(|| panic!("no connection named {name}")) +} + +fn query<'a>(collected: &'a CollectedImport, name: &str) -> Option<&'a CollectedSavedQuery> { + collected.saved_queries.iter().find(|query| query.name == name) +} + +fn query_names(collected: &CollectedImport) -> BTreeSet<&str> { + collected + .saved_queries + .iter() + .map(|query| query.name.as_str()) + .collect() +} + +fn field<'a>(connection: &'a CollectedConnection, key: &str) -> Option<&'a str> { + connection.settings.additional_fields.get(key).map(String::as_str) +} + +fn exposed(secret: Option<&SecretString>) -> Option<&str> { + secret.map(|secret| secret.expose_secret()) +} + +fn text(query: &CollectedSavedQuery) -> &str { + match &query.content { + SavedQueryContent::Text(text) => text, + SavedQueryContent::Oversized { .. } => panic!("{} is oversized", query.name), + } +} + +fn ssl_mode(collected: &CollectedImport, name: &str) -> Option { + connection(collected, name).settings.ssl.as_ref().map(|ssl| ssl.mode) +} + +#[test] +fn the_app_is_tableplus_reading_keychain_passwords_and_global_queries() { + let app = ForeignApp::TablePlus; + assert_eq!(app.id(), "tableplus"); + assert_eq!(app.display_name(), "TablePlus"); + assert_eq!(app.password_source(), PasswordSource::Keychain); + assert_eq!(app.saved_query_support(), SavedQuerySupport::GlobalFolder); + assert!(PLATFORMS.into_iter().all(|platform| app.runs_on(platform))); +} + +#[test] +fn inventory_counts_every_readable_connection_and_every_non_empty_favorite() { + let inventory = importer(ForeignPlatform::MacOs, &fixture("macos"), None).inventory_blocking(); + // The two entries with no name that collect skips are not counted. + assert_eq!( + inventory, + Inventory { + connections: 105, + saved_queries: 6 + } + ); +} + +#[test] +fn an_empty_home_has_nothing_on_any_platform() { + let home = tempfile::tempdir().expect("a temp dir"); + for platform in PLATFORMS { + let tableplus = importer(platform, home.path(), None); + assert_eq!(tableplus.inventory_blocking(), Inventory::default()); + assert!(!tableplus.is_available_blocking()); + assert!(matches!( + tableplus.collect_blocking(&CONNECTIONS_ONLY, &CancellationToken::new()), + Err(ForeignImportError::FileNotFound { + app: ForeignApp::TablePlus + }) + )); + } +} + +#[test] +fn collect_reads_every_named_connection_and_skips_the_rest() { + let collected = macos(CONNECTIONS_ONLY); + assert_eq!(collected.app, ForeignApp::TablePlus); + assert_eq!(collected.connections.len(), 105); + assert!(importer(ForeignPlatform::MacOs, &fixture("macos"), None).is_available_blocking()); +} + +#[test] +fn collect_keys_each_connection_by_its_tableplus_id() { + let collected = macos(CONNECTIONS_ONLY); + assert_eq!(connection(&collected, "Bundle DB").reference, "conn-7"); + assert_eq!(collected.passwords, PasswordOutcome::NotRequested); + assert!(collected.saved_queries.is_empty()); +} + +#[test] +fn collect_maps_every_driver_tableplus_writes() { + let collected = macos(CONNECTIONS_ONLY); + let mappings = [ + ("MicrosoftSQLServer", "SQL Server"), + ("Mongo", "MongoDB"), + ("Cockroach", "CockroachDB"), + ("CloudflareD1", "Cloudflare D1"), + ("LibSQL", "libSQL"), + ("ElasticSearch", "Elasticsearch"), + ("MySQL", "MySQL"), + ("MariaDB", "MariaDB"), + ("PostgreSQL", "PostgreSQL"), + ("Redshift", "Redshift"), + ("Redis", "Redis"), + ("Oracle", "Oracle"), + ("SQLite", "SQLite"), + ("DuckDB", "DuckDB"), + ("ClickHouse", "ClickHouse"), + ("BigQuery", "BigQuery"), + ("DynamoDB", "DynamoDB"), + ("Snowflake", "Snowflake"), + ("Cassandra", "Cassandra"), + ("Vertica", "Vertica"), + ("Greenplum", "Greenplum"), + ]; + for (driver, engine) in mappings { + assert_eq!( + connection(&collected, &format!("Conn {driver}")).settings.engine, + engine, + "{driver}" + ); + } +} + +#[test] +fn collect_keeps_an_explicit_key_path_even_when_the_file_is_missing() { + let collected = macos(CONNECTIONS_ONLY); + let ssh = connection(&collected, "SSH DB") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.host, "bastion.example.com"); + assert_eq!(ssh.port, Some(2222)); + assert_eq!(ssh.username, "deploy"); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, "/Users/test/.ssh/id_rsa"); + assert!(ssh.jump_hosts.is_empty()); +} + +#[test] +fn collect_drops_the_empty_key_placeholder_instead_of_building_a_fake_path() { + let collected = macos(CONNECTIONS_ONLY); + let ssh = connection(&collected, "SSH Placeholder") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, ""); +} + +#[test] +fn collect_keeps_a_bare_key_name_when_the_file_is_in_the_ssh_folder() { + let collected = macos(CONNECTIONS_ONLY); + let ssh = connection(&collected, "SSH Bare Key") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.private_key_path, "~/.ssh/id_tableplus_fixture"); +} + +#[test] +fn collect_reads_password_sign_in_and_integer_flags_for_a_tunnel() { + let collected = macos(CONNECTIONS_ONLY); + let password = connection(&collected, "SSH Password DB") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(password.auth, ForeignSshAuth::Password); + assert_eq!(password.private_key_path, ""); + assert_eq!(password.port, Some(22)); + + let integers = connection(&collected, "SSH Integer Flags") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(integers.auth, ForeignSshAuth::PrivateKey); + assert_eq!(integers.private_key_path, "/keys/id_ed25519"); + assert!(connection(&collected, "No SSL").settings.ssh.is_none()); +} + +#[test] +fn collect_reads_tls_key_paths_in_tableplus_order_key_certificate_authority() { + let collected = macos(CONNECTIONS_ONLY); + let ssl = connection(&collected, "SSL DB").settings.ssl.as_ref().expect("TLS"); + assert_eq!(ssl.mode, ForeignSslMode::Required); + assert_eq!(ssl.ca_certificate_path.as_deref(), Some("/path/to/ca.pem")); + assert_eq!(ssl.client_certificate_path.as_deref(), Some("/path/to/client-cert.pem")); + assert_eq!(ssl.client_key_path.as_deref(), Some("/path/to/client-key.pem")); +} + +#[test] +fn collect_sets_no_tls_without_the_mode_key_and_preferred_for_mode_zero() { + let collected = macos(CONNECTIONS_ONLY); + assert!(connection(&collected, "No SSL").settings.ssl.is_none()); + assert_eq!(ssl_mode(&collected, "Prefer SSL"), Some(ForeignSslMode::Preferred)); +} + +#[test] +fn collect_reads_the_tls_mode_against_the_popup_of_the_drivers_own_form() { + use ForeignSslMode::{Disabled, Preferred, Required, VerifyCa, VerifyIdentity}; + let collected = macos(CONNECTIONS_ONLY); + let cases = [ + ("MySQL 0", Preferred), + ("MySQL 1", Disabled), + ("MySQL 2", Required), + ("MySQL 3", VerifyCa), + ("MySQL 4", VerifyIdentity), + ("MariaDB 1", Required), + ("MariaDB 2", VerifyIdentity), + ("PostgreSQL 1", Disabled), + ("PostgreSQL 2", Required), + ("PostgreSQL 3", Preferred), + ("PostgreSQL 4", VerifyCa), + ("PostgreSQL 5", VerifyIdentity), + ("Cockroach 5", VerifyIdentity), + ("Redshift 4", VerifyCa), + ("Cassandra 0", Disabled), + ("Cassandra 2", VerifyCa), + ("Cassandra 4", VerifyIdentity), + ("Redis 0", Disabled), + ("Redis 2", VerifyCa), + ("ClickHouse 1", VerifyIdentity), + ("ClickHouse 2", Required), + ("Mongo 1", VerifyIdentity), + ("ElasticSearch 1", VerifyIdentity), + ("ElasticSearch 2", VerifyCa), + ("ElasticSearch 3", Required), + ("Oracle 1", Required), + ]; + for (name, mode) in cases { + assert_eq!(ssl_mode(&collected, name), Some(mode), "{name}"); + } +} + +#[test] +fn collect_reads_the_tls_slots_each_drivers_own_form_writes() { + let collected = macos(CONNECTIONS_ONLY); + let ssl = |name: &str| connection(&collected, name).settings.ssl.clone().expect("TLS"); + + let cassandra = ssl("Cassandra TLS"); + assert_eq!(cassandra.client_key_path.as_deref(), Some("/path/to/cass-key.pem")); + assert_eq!( + cassandra.client_certificate_path.as_deref(), + Some("/path/to/cass-cert.pem") + ); + assert_eq!(cassandra.ca_certificate_path.as_deref(), Some("/path/to/cass-ca.pem")); + + let authority_only = ssl("Cassandra CA only"); + assert_eq!( + authority_only.ca_certificate_path.as_deref(), + Some("/path/to/cass-ca.pem") + ); + assert_eq!(authority_only.client_key_path, None); + assert_eq!(authority_only.client_certificate_path, None); + + let mongo = ssl("Mongo TLS"); + assert_eq!( + mongo.client_certificate_path.as_deref(), + Some("/path/to/mongo-certkey.pem") + ); + assert_eq!(mongo.ca_certificate_path.as_deref(), Some("/path/to/mongo-ca.pem")); + assert_eq!(mongo.client_key_path, None); + + let clickhouse = ssl("ClickHouse TLS"); + assert_eq!( + clickhouse.ca_certificate_path.as_deref(), + Some("/path/to/clickhouse-ca.pem") + ); + assert_eq!(clickhouse.client_certificate_path, None); + assert_eq!(clickhouse.client_key_path, None); + + let elasticsearch = ssl("Elasticsearch TLS"); + assert_eq!(elasticsearch.ca_certificate_path.as_deref(), Some("/path/to/es-ca.pem")); + assert_eq!(elasticsearch.client_certificate_path, None); +} + +#[test] +fn collect_carries_no_tls_paths_for_a_driver_whose_form_has_no_file_pickers() { + let collected = macos(CONNECTIONS_ONLY); + let ssl = connection(&collected, "Oracle TLS").settings.ssl.clone().expect("TLS"); + assert_eq!(ssl.mode, ForeignSslMode::Required); + assert_eq!(ssl.ca_certificate_path, None); + assert_eq!(ssl.client_certificate_path, None); + assert_eq!(ssl.client_key_path, None); +} + +#[test] +fn collect_drops_a_tls_mode_past_the_end_of_the_drivers_own_popup() { + let collected = macos(CONNECTIONS_ONLY); + for name in ["Unknown TLS", "Past MySQL", "Past Oracle"] { + assert!(connection(&collected, name).settings.ssl.is_none(), "{name}"); + } +} + +#[test] +fn collect_keeps_preferred_for_a_driver_whose_form_has_no_tls_picker() { + let collected = macos(CONNECTIONS_ONLY); + assert_eq!(ssl_mode(&collected, "SQL Server TLS"), Some(ForeignSslMode::Preferred)); + assert_eq!(ssl_mode(&collected, "Snowflake TLS"), Some(ForeignSslMode::Preferred)); +} + +#[test] +fn collect_treats_empty_tls_paths_as_none() { + let collected = macos(CONNECTIONS_ONLY); + let ssl = connection(&collected, "Empty TLS").settings.ssl.clone().expect("TLS"); + assert_eq!(ssl.ca_certificate_path, None); + assert_eq!(ssl.client_certificate_path, None); + assert_eq!(ssl.client_key_path, None); +} + +#[test] +fn collect_keeps_groups_walking_up_through_each_groups_parent() { + let collected = macos(CONNECTIONS_ONLY); + let path = |name: &str| connection(&collected, name).group_path.clone(); + assert_eq!(path("Prod DB"), ["Production"]); + assert_eq!(path("Dev DB"), ["Development"]); + assert_eq!(path("Replica DB"), ["Production", "Replicas"]); + assert!(path("Ungrouped").is_empty()); + assert!(path("Lost Group DB").is_empty()); +} + +#[test] +fn collect_reads_the_port_from_text_or_a_number() { + let collected = macos(CONNECTIONS_ONLY); + assert_eq!(connection(&collected, "Custom Port").settings.port, 5433); + assert_eq!(connection(&collected, "Integer Port").settings.port, 6543); +} + +#[test] +fn collect_uses_the_engines_default_port_when_none_is_saved() { + let collected = macos(CONNECTIONS_ONLY); + let cases = [ + ("MySQL No Port", 3306), + ("PG No Port", 5432), + ("Mongo No Port", 27017), + ("Redis No Port", 6379), + ("SQL Server No Port", 1433), + ("Cockroach No Port", 26257), + ("Redshift No Port", 5439), + ("Vertica No Port", 0), + ]; + for (name, port) in cases { + assert_eq!(connection(&collected, name).settings.port, port, "{name}"); + } +} + +#[test] +fn collect_without_passwords_reads_no_keychain() { + let keychain = Keychain::answering(&[("conn-1_database", Answer::Found("s3cret"))]); + let collected = try_collect( + ForeignPlatform::MacOs, + &fixture("macos"), + CONNECTIONS_ONLY, + Some(Arc::clone(&keychain)), + ) + .expect("an import"); + assert!( + collected + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); + assert!(keychain.calls().is_empty()); +} + +#[test] +fn collect_with_an_empty_connection_list_finds_nothing() { + let outcome = try_collect(ForeignPlatform::MacOs, &fixture("macos-empty"), CONNECTIONS_ONLY, None); + assert!(matches!(outcome, Err(ForeignImportError::NothingFound))); +} + +#[test] +fn collect_with_only_unnamed_connections_finds_nothing() { + let outcome = try_collect( + ForeignPlatform::MacOs, + &fixture("macos-invalid"), + CONNECTIONS_ONLY, + None, + ); + assert!(matches!(outcome, Err(ForeignImportError::NothingFound))); + // Entries collect cannot read are not counted as found. + let inventory = importer(ForeignPlatform::MacOs, &fixture("macos-invalid"), None).inventory_blocking(); + assert_eq!(inventory.connections, 0); +} + +#[test] +fn collect_refuses_a_connection_file_that_is_not_a_list_of_dictionaries() { + let home = writable("macos-empty"); + let file = home + .path() + .join("Library/Application Support/com.tinyapp.TablePlus/Data/Connections.plist"); + fs::write(&file, "not a plist").expect("a file"); + let outcome = try_collect(ForeignPlatform::MacOs, home.path(), CONNECTIONS_ONLY, None); + assert!(matches!(outcome, Err(ForeignImportError::UnsupportedFormat { .. }))); + assert_eq!( + importer(ForeignPlatform::MacOs, home.path(), None).inventory_blocking(), + Inventory::default() + ); +} + +#[test] +fn collect_maps_environments_to_colours() { + let collected = macos(CONNECTIONS_ONLY); + let color = |name: &str| connection(&collected, name).settings.color; + assert_eq!(color("Staging"), Some(ForeignColor::Yellow)); + assert_eq!(color("Prod"), Some(ForeignColor::Red)); + assert_eq!(color("Test"), Some(ForeignColor::Blue)); + assert_eq!(color("Dev"), Some(ForeignColor::Green)); + assert_eq!(color("None"), None); +} + +#[test] +fn collect_puts_a_file_path_in_the_field_its_engine_reads() { + let collected = macos(CONNECTIONS_ONLY); + let sqlite = connection(&collected, "Local SQLite"); + assert_eq!(sqlite.settings.database, "/Users/me/data.db"); + assert!(sqlite.settings.additional_fields.is_empty()); + + let duckdb = connection(&collected, "Local DuckDB"); + assert_eq!(duckdb.settings.engine, "DuckDB"); + assert_eq!(field(duckdb, "duckdbFilePath"), Some("/Users/me/warehouse.duckdb")); + assert_eq!(duckdb.settings.database, ""); + assert_eq!(duckdb.settings.port, 0); + + let server = connection(&collected, "Server Path"); + assert_eq!(server.settings.database, "sales"); + assert!(server.settings.additional_fields.is_empty()); +} + +#[test] +fn collect_reads_the_database_password_from_the_tableplus_keychain_service() { + let keychain = Keychain::answering(&[("conn-1_database", Answer::Found("s3cret"))]); + let collected = macos_with_keychain(&keychain); + assert!( + keychain + .calls() + .contains(&(SERVICE.to_owned(), "conn-1_database".to_owned())) + ); + let credentials = connection(&collected, "Keychain DB") + .credentials + .as_ref() + .expect("credentials"); + assert_eq!(exposed(credentials.password.as_ref()), Some("s3cret")); + assert_eq!(collected.passwords, PasswordOutcome::Read); +} + +#[test] +fn collect_asks_for_the_database_tunnel_and_key_passphrase_accounts() { + let keychain = Keychain::answering(&[]); + macos_with_keychain(&keychain); + let expected: BTreeSet = ["conn-1_database", "conn-1_server", "conn-1_server_key"] + .into_iter() + .map(str::to_owned) + .collect(); + assert_eq!(keychain.accounts_of("conn-1"), expected); + assert!(keychain.calls().iter().all(|(service, _)| service == SERVICE)); +} + +#[test] +fn collect_leaves_credentials_empty_and_goes_on_when_nothing_is_stored() { + let keychain = Keychain::answering(&[]); + let collected = macos_with_keychain(&keychain); + assert!( + collected + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); + assert_eq!(collected.passwords, PasswordOutcome::Read); +} + +#[test] +fn collect_stops_reading_after_a_refused_keychain_prompt() { + let keychain = Keychain::answering(&[("conn-1_database", Answer::Denied)]); + let collected = macos_with_keychain(&keychain); + assert_eq!(collected.passwords, PasswordOutcome::Stopped); + assert_eq!(keychain.calls(), [(SERVICE.to_owned(), "conn-1_database".to_owned())]); + assert_eq!(collected.connections.len(), 105); +} + +#[test] +fn collect_carries_ask_every_time_and_command_line_across_as_a_prompt() { + let collected = macos(CONNECTIONS_ONLY); + assert_eq!(field(connection(&collected, "Ask"), "promptForPassword"), Some("true")); + assert_eq!( + field(connection(&collected, "Command"), "promptForPassword"), + Some("true") + ); + for name in ["Stored", "Absent", "Unknown", "No Password"] { + assert_eq!(field(connection(&collected, name), "promptForPassword"), None, "{name}"); + } +} + +#[test] +fn collect_keeps_the_local_file_path_beside_the_prompt() { + let collected = macos(CONNECTIONS_ONLY); + let libsql = connection(&collected, "libSQL Ask"); + assert_eq!(field(libsql, "libsqlFilePath"), Some("/Users/me/local.db")); + assert_eq!(field(libsql, "promptForPassword"), Some("true")); +} + +#[test] +fn collect_sets_no_prompt_where_the_engine_signs_in_its_own_way() { + let collected = macos(CONNECTIONS_ONLY); + let duckdb = connection(&collected, "DuckDB Ask"); + assert_eq!(field(duckdb, "duckdbFilePath"), Some("/Users/me/warehouse.duckdb")); + assert_eq!(field(duckdb, "promptForPassword"), None); + assert_eq!(field(connection(&collected, "DynamoDB Ask"), "promptForPassword"), None); +} + +#[test] +fn collect_skips_the_keychain_for_a_database_password_tableplus_does_not_store() { + let keychain = Keychain::answering(&[("pm-ask_database", Answer::Found("stale"))]); + let collected = macos_with_keychain(&keychain); + assert!(!keychain.accounts_of("pm-ask").contains("pm-ask_database")); + let password = connection(&collected, "Ask") + .credentials + .as_ref() + .and_then(|credentials| credentials.password.as_ref()); + assert!(password.is_none()); +} + +#[test] +fn collect_skips_the_tunnel_password_but_keeps_the_key_passphrase() { + let keychain = Keychain::answering(&[ + ("pm-ask-ssh_server", Answer::Found("stale")), + ("pm-ask-ssh_server_key", Answer::Found("passphrase")), + ]); + let collected = macos_with_keychain(&keychain); + let expected: BTreeSet = ["pm-ask-ssh_database", "pm-ask-ssh_server_key"] + .into_iter() + .map(str::to_owned) + .collect(); + assert_eq!(keychain.accounts_of("pm-ask-ssh"), expected); + let credentials = connection(&collected, "Ask SSH") + .credentials + .as_ref() + .expect("credentials"); + assert!(credentials.ssh_password.is_none()); + assert_eq!(exposed(credentials.key_passphrase.as_ref()), Some("passphrase")); +} + +#[test] +fn collect_reads_the_keychain_for_a_mode_tableplus_has_not_shipped_yet() { + let keychain = Keychain::answering(&[("pm-unknown_database", Answer::Found("s3cret"))]); + let collected = macos_with_keychain(&keychain); + let unknown = connection(&collected, "Unknown"); + let credentials = unknown.credentials.as_ref().expect("credentials"); + assert_eq!(exposed(credentials.password.as_ref()), Some("s3cret")); + assert_eq!(field(unknown, "promptForPassword"), None); +} + +#[test] +fn collect_carries_a_dynamodb_region_and_access_key_into_its_sign_in_fields() { + let collected = macos(CONNECTIONS_ONLY); + let orders = connection(&collected, "Orders"); + assert_eq!(orders.settings.engine, "DynamoDB"); + assert_eq!(field(orders, "awsRegion"), Some("eu-west-1")); + assert_eq!(field(orders, "awsAccessKeyId"), Some("AKIAEXAMPLE")); + assert_eq!(field(orders, "awsAuthMethod"), Some("credentials")); + + let no_region = connection(&collected, "Orders No Region"); + assert_eq!(field(no_region, "awsRegion"), None); + assert_eq!(field(no_region, "awsAccessKeyId"), None); + assert_eq!(field(no_region, "awsAuthMethod"), Some("credentials")); + + let shop = connection(&collected, "Shop"); + assert_eq!(field(shop, "awsRegion"), None); + assert_eq!(field(shop, "awsAuthMethod"), None); +} + +#[test] +fn collect_reads_every_favorite_as_a_global_query_under_the_tableplus_folder() { + let collected = macos(WITH_SAVED_QUERIES); + assert_eq!( + query_names(&collected), + BTreeSet::from(["tp_top", "tp_root_kw", "nested", "two.dots", "notes", "deep"]) + ); + + let top = query(&collected, "tp_top").expect("tp_top"); + assert_eq!(text(top), "SELECT 1;"); + assert_eq!(top.keyword, None); + assert_eq!(top.connection, None); + assert_eq!(top.folder_path, ["TablePlus"]); + assert_eq!( + query(&collected, "tp_root_kw").expect("tp_root_kw").keyword.as_deref(), + Some("kwone") + ); + + let nested = query(&collected, "nested").expect("nested"); + assert_eq!(text(nested), "-- c\nSELECT 3;\n"); + assert_eq!(nested.keyword.as_deref(), Some("kwnested")); + assert_eq!(nested.folder_path, ["TablePlus", "Folder A"]); + + let deep = query(&collected, "deep").expect("deep"); + assert_eq!(text(deep), "SELECT 'Việt ✓';"); + assert_eq!(deep.folder_path, ["TablePlus", "Folder A", "Sub"]); + + assert!(collected.saved_queries.iter().all(|query| query.suggested)); +} + +#[test] +fn collect_keeps_the_first_keyword_of_the_tag_file_that_has_no_space() { + let home = writable("macos"); + fs::write( + home.path() + .join("Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/tp_top.sql.tag"), + "Tp Space Kw,tpok,later", + ) + .expect("a tag file"); + let collected = collect(ForeignPlatform::MacOs, home.path(), WITH_SAVED_QUERIES); + assert_eq!( + query(&collected, "tp_top").expect("tp_top").keyword.as_deref(), + Some("tpok") + ); + assert_eq!( + query(&collected, "tp_root_kw").expect("tp_root_kw").keyword.as_deref(), + Some("kwone") + ); +} + +#[test] +fn a_favorite_over_the_size_limit_is_listed_by_size_only() { + let home = writable("macos"); + let huge = "x".repeat(900_001); + fs::write( + home.path() + .join("Library/Application Support/com.tinyapp.TablePlus/Data/Favorite/huge.sql"), + huge, + ) + .expect("a file"); + let collected = collect(ForeignPlatform::MacOs, home.path(), WITH_SAVED_QUERIES); + let oversized = query(&collected, "huge").expect("huge"); + assert_eq!(oversized.content, SavedQueryContent::Oversized { byte_count: 900_001 }); + assert_eq!(oversized.folder_path, ["TablePlus"]); + assert!(!oversized.suggested); +} + +#[test] +fn the_setapp_edition_is_read_when_only_it_is_there() { + let home = fixture("macos-setapp"); + let collected = collect(ForeignPlatform::MacOs, &home, WITH_SAVED_QUERIES); + let names: Vec<&str> = collected + .connections + .iter() + .map(|connection| connection.settings.name.as_str()) + .collect(); + assert_eq!(names, ["Setapp DB"]); + assert_eq!(query_names(&collected), BTreeSet::from(["setapp", "shared"])); +} + +#[test] +fn a_shared_favorites_folder_lands_under_its_own_name_inside_the_tableplus_folder() { + let home = fixture("macos-setapp"); + let collected = collect(ForeignPlatform::MacOs, &home, WITH_SAVED_QUERIES); + let shared = query(&collected, "shared").expect("shared"); + assert_eq!(shared.keyword.as_deref(), Some("kwshared")); + assert_eq!(shared.folder_path, ["TablePlus", "tp_shared"]); + let inventory = importer(ForeignPlatform::MacOs, &home, None).inventory_blocking(); + assert_eq!( + inventory, + Inventory { + connections: 1, + saved_queries: 2 + } + ); +} + +#[test] +fn the_shared_connection_path_holds_the_connection_files_directly() { + let collected = collect(ForeignPlatform::MacOs, &fixture("macos-shared"), CONNECTIONS_ONLY); + assert_eq!(collected.connections.len(), 1); + let moved = connection(&collected, "Moved DB"); + assert_eq!(moved.group_path, ["Moved"]); +} + +#[test] +fn the_shared_query_path_moves_the_favorites_to_its_favorite_folder() { + let collected = collect(ForeignPlatform::MacOs, &fixture("macos-shared"), WITH_SAVED_QUERIES); + assert_eq!(query_names(&collected), BTreeSet::from(["custom"])); +} + +#[test] +fn a_macos_keychain_left_unreachable_leaves_the_passwords_there() { + let collected = + try_collect(ForeignPlatform::MacOs, &fixture("macos-setapp"), WITH_PASSWORDS, None).expect("an import"); + assert_eq!(collected.passwords, PasswordOutcome::LeftInKeychain); +} + +fn quoted(path: &Path) -> String { + serde_json::to_string(path).expect("a path") +} + +fn windows_home() -> tempfile::TempDir { + let home = writable("windows"); + let base = home.path().join("AppData/Local/com.tinyapp.TablePlus"); + let sources = format!( + r#"{{"sources":[{{"id":"local","name":"Local","path":{}}},{{"id":"team","name":"Team","path":{}}}]}}"#, + quoted(&base.join("Data")), + quoted(&home.path().join("Team")), + ); + fs::write(base.join("ConnectionSources.json"), sources).expect("a file"); + let setting = format!( + r#"{{"favorites":[{{"path":{}}}],"theme":1}}"#, + quoted(&home.path().join("Team Queries")) + ); + fs::write(base.join("Setting.json"), setting).expect("a file"); + home +} + +#[test] +fn windows_reads_the_local_folder_then_every_listed_connection_source() { + let home = windows_home(); + let collected = collect(ForeignPlatform::Windows, home.path(), CONNECTIONS_ONLY); + let names: Vec<&str> = collected + .connections + .iter() + .map(|connection| connection.settings.name.as_str()) + .collect(); + assert_eq!( + names, + ["Win MySQL", "Win SQLite", "Win DuckDB", "Win Postgres", "Team Redis"] + ); + assert_eq!(connection(&collected, "Team Redis").group_path, ["Shared"]); + assert_eq!( + importer(ForeignPlatform::Windows, home.path(), None).inventory_blocking(), + Inventory { + connections: 5, + saved_queries: 2 + } + ); +} + +#[test] +fn windows_reads_integer_flags_nested_groups_and_the_macos_keys() { + let home = windows_home(); + let collected = collect(ForeignPlatform::Windows, home.path(), CONNECTIONS_ONLY); + let mysql = connection(&collected, "Win MySQL"); + assert_eq!(mysql.group_path, ["Servers", "EU"]); + assert_eq!(mysql.settings.color, Some(ForeignColor::Red)); + let ssh = mysql.settings.ssh.as_ref().expect("a tunnel"); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, "C:\\Users\\me\\.ssh\\id_ed25519"); + let ssl = mysql.settings.ssl.as_ref().expect("TLS"); + assert_eq!(ssl.mode, ForeignSslMode::Required); + assert_eq!(ssl.client_key_path.as_deref(), Some("C:\\tls\\client.key")); + assert_eq!(ssl.ca_certificate_path.as_deref(), Some("C:\\tls\\ca.pem")); + + assert_eq!( + connection(&collected, "Win SQLite").group_path, + ["Servers", "EU", "Prod"] + ); + let postgres = connection(&collected, "Win Postgres"); + assert_eq!(postgres.settings.engine, "PostgreSQL"); + assert_eq!( + postgres.settings.ssl.as_ref().map(|ssl| ssl.mode), + Some(ForeignSslMode::VerifyCa) + ); + assert_eq!(field(postgres, "promptForPassword"), Some("true")); +} + +#[test] +fn windows_keeps_a_file_engines_path_from_the_host_field() { + let home = windows_home(); + let collected = collect(ForeignPlatform::Windows, home.path(), CONNECTIONS_ONLY); + let sqlite = connection(&collected, "Win SQLite"); + assert_eq!(sqlite.settings.database, "C:\\data\\app.db"); + assert_eq!(sqlite.settings.host, "localhost"); + let duckdb = connection(&collected, "Win DuckDB"); + assert_eq!(field(duckdb, "duckdbFilePath"), Some("C:\\data\\lake.duckdb")); + assert_eq!(duckdb.settings.port, 0); +} + +#[test] +fn windows_reads_favorites_with_keywords_and_shared_folders() { + let home = windows_home(); + let collected = collect(ForeignPlatform::Windows, home.path(), WITH_SAVED_QUERIES); + let report = query(&collected, "report").expect("report"); + assert_eq!(report.keyword.as_deref(), Some("daily")); + assert_eq!(report.folder_path, ["TablePlus"]); + assert_eq!( + query(&collected, "team").expect("team").folder_path, + ["TablePlus", "Team Queries"] + ); +} + +#[test] +fn windows_follows_the_shared_query_path_in_its_settings() { + let home = windows_home(); + let setting = format!(r#"{{"sharedQueryPath":{}}}"#, quoted(&home.path().join("Queries"))); + fs::write( + home.path().join("AppData/Local/com.tinyapp.TablePlus/Setting.json"), + setting, + ) + .expect("a file"); + let collected = collect(ForeignPlatform::Windows, home.path(), WITH_SAVED_QUERIES); + assert_eq!(query_names(&collected), BTreeSet::from(["moved"])); +} + +#[test] +fn windows_leaves_passwords_in_its_credential_store() { + let home = windows_home(); + let keychain = Keychain::answering(&[]); + let collected = try_collect( + ForeignPlatform::Windows, + home.path(), + WITH_PASSWORDS, + Some(Arc::clone(&keychain)), + ) + .expect("an import"); + assert_eq!(collected.passwords, PasswordOutcome::LeftInKeychain); + assert!(keychain.calls().is_empty()); +} + +#[test] +fn linux_reads_the_json_connections_with_their_nested_groups() { + let collected = collect(ForeignPlatform::Linux, &fixture("linux"), CONNECTIONS_ONLY); + assert_eq!(collected.connections.len(), 3); + + let postgres = connection(&collected, "Linux Postgres"); + assert_eq!(postgres.reference, "lx-1"); + assert_eq!(postgres.settings.engine, "PostgreSQL"); + assert_eq!(postgres.settings.host, "db.internal"); + assert_eq!(postgres.settings.port, 5433); + assert_eq!(postgres.settings.username, "app"); + assert_eq!(postgres.settings.database, "shop"); + assert_eq!(postgres.group_path, ["Work", "Databases"]); + let ssh = postgres.settings.ssh.as_ref().expect("a tunnel"); + assert_eq!(ssh.host, "bastion"); + assert_eq!(ssh.port, Some(2222)); + assert_eq!(ssh.auth, ForeignSshAuth::PrivateKey); + assert_eq!(ssh.private_key_path, "/home/me/.ssh/id_ed25519"); + assert!(postgres.settings.ssl.is_none()); + // Its `ssl-mode` has no known meaning, so it is marked as TLS not read rather than dropped. + assert_eq!(field(postgres, "tlsSettingsUnread"), Some("true")); + assert_eq!(postgres.settings.color, None); + + let sqlite = connection(&collected, "Linux SQLite"); + assert_eq!(sqlite.settings.database, "/home/me/data.db"); + assert!(sqlite.settings.ssh.is_none()); + assert_eq!(field(sqlite, "tlsSettingsUnread"), None); + + let command = connection(&collected, "Linux Command"); + assert_eq!(command.settings.port, 3306); + assert_eq!(field(command, "promptForPassword"), Some("true")); +} + +#[test] +fn linux_reads_queries_without_keywords_and_its_shared_folders() { + let home = writable("linux"); + let settings = format!( + r#"{{"theme":2,"shared-folder-paths":[{}]}}"#, + quoted(&home.path().join("Shared")) + ); + fs::write(home.path().join(".tableplus/settings/settings.json"), settings).expect("a file"); + let collected = collect(ForeignPlatform::Linux, home.path(), WITH_SAVED_QUERIES); + assert_eq!(query_names(&collected), BTreeSet::from(["daily", "monthly", "team"])); + let monthly = query(&collected, "monthly").expect("monthly"); + assert_eq!(monthly.keyword, None); + assert_eq!(monthly.folder_path, ["TablePlus", "Reports"]); + assert_eq!( + query(&collected, "team").expect("team").folder_path, + ["TablePlus", "Shared"] + ); +} + +#[test] +fn linux_leaves_passwords_in_the_secret_service() { + let collected = try_collect(ForeignPlatform::Linux, &fixture("linux"), WITH_PASSWORDS, None).expect("an import"); + assert_eq!(collected.passwords, PasswordOutcome::LeftInKeychain); + assert!( + collected + .connections + .iter() + .all(|connection| connection.credentials.is_none()) + ); +} diff --git a/core/crates/storage/src/connections/additions.rs b/core/crates/storage/src/connections/additions.rs new file mode 100644 index 0000000000..60888a5542 --- /dev/null +++ b/core/crates/storage/src/connections/additions.rs @@ -0,0 +1,259 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use super::{ConnectionList, ConnectionStore, ConnectionsDocument, SavedConnection, unclaimed_name}; +use crate::error::StorageError; +use crate::{ConnectionGroup, ConnectionGroupName}; + +// A connection an import brings from somewhere other than an exported list, such as another app. +#[derive(Debug, Clone)] +pub struct ImportedConnection { + pub connection: SavedConnection, + // Matched in any case in its place and made where missing; names past MAX_DEPTH are dropped. + pub group_path: Vec, + pub replaces: Option, +} + +impl ConnectionStore { + // The list the user reviewed must still be the list, since a duplicate found then may be gone. + // A replacement keeps the saved id only when it connects alike, as the keyring files secrets by id. + pub fn add_imported_blocking( + &self, + expected: &ConnectionList, + imported: Vec, + ) -> Result, StorageError> { + let mut state = self.lock(); + let mut document = self.ensure_loaded(&mut state)?; + if !document.lists_as(expected) { + return Err(StorageError::ImportOutdated); + } + let held: HashMap = document + .connections + .iter() + .map(|saved| (saved.name.clone(), saved.id)) + .collect(); + let mut saved = Vec::with_capacity(imported.len()); + for entry in imported { + let group_id = document.ensure_group_path(&entry.group_path); + let mut connection = entry.connection; + connection.group_id = group_id; + connection.last_opened_at = None; + let target = entry.replaces.and_then(|id| { + document.connections.iter().position(|existing| { + existing.id == id + && existing.connects_like(&SavedConnection { + id, + ..connection.clone() + }) + }) + }); + match target { + Some(index) => { + let existing = &document.connections[index]; + connection.id = existing.id; + connection.order = if existing.group_id == connection.group_id { + existing.order + } else { + document.next_connection_order(connection.group_id) + }; + connection.keep_marks_from(existing); + connection.name = + unclaimed_name(&document.connections, &held, &connection.name, Some(connection.id)); + document.connections[index] = connection.clone(); + } + None => { + connection.id = Uuid::new_v4(); + connection.order = document.next_connection_order(connection.group_id); + connection.name = unclaimed_name(&document.connections, &held, &connection.name, None); + document.connections.push(connection.clone()); + } + } + saved.push(connection); + } + if saved.is_empty() { + return Ok(saved); + } + self.commit(&mut state, document)?; + Ok(saved) + } +} + +impl SavedConnection { + // Marks only this app sets, which the other app never had. + fn keep_marks_from(&mut self, existing: &SavedConnection) { + self.tags.clone_from(&existing.tags); + self.icon = existing.icon; + self.last_opened_at = existing.last_opened_at; + } +} + +impl ConnectionsDocument { + fn lists_as(&self, list: &ConnectionList) -> bool { + self.connections.as_slice() == &*list.connections + && self.groups.as_slice() == &*list.groups + && self.tags.as_slice() == &*list.tags + && self.favorites.as_slice() == &*list.favorites + } + + fn ensure_group_path(&mut self, path: &[ConnectionGroupName]) -> Option { + let mut parent = None; + for name in path.iter().take(ConnectionGroup::MAX_DEPTH) { + let group = match self.group_named(parent, name) { + Some(group) => group, + None => { + let group = ConnectionGroup { + order: self.next_group_order(parent), + ..ConnectionGroup::new(name.clone(), parent) + }; + self.groups.push(group.clone()); + group + } + }; + parent = Some(group.id); + } + parent + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::{SafeModeLevel, TlsConfig}; + + use super::*; + use crate::{ConnectionColor, StoragePaths}; + + fn store() -> (tempfile::TempDir, ConnectionStore) { + let dir = tempfile::tempdir().expect("a temp dir"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", "app.tablepro.TablePro")); + (dir, store) + } + + fn connection(name: &str, host: &str) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: host.to_owned(), + port: 5432, + database: "app".to_owned(), + username: "admin".to_owned(), + tls: TlsConfig::disabled(), + safe_mode: SafeModeLevel::Off, + external_access: Default::default(), + ai_policy: Default::default(), + query_timeout_secs: None, + auth_mode: Default::default(), + ssh: None, + last_opened_at: None, + color: None, + group_id: None, + order: 0, + tags: Vec::new(), + icon: None, + options: Default::default(), + } + } + + fn name(value: &str) -> ConnectionGroupName { + ConnectionGroupName::new(value).expect("a group name") + } + + fn list(store: &ConnectionStore) -> ConnectionList { + store.list_blocking().expect("the list") + } + + fn add(store: &ConnectionStore, imported: Vec) -> Vec { + let before = list(store); + store.add_imported_blocking(&before, imported).expect("the import") + } + + fn added(connection: SavedConnection, path: &[&str]) -> ImportedConnection { + ImportedConnection { + connection, + group_path: path.iter().map(|part| name(part)).collect(), + replaces: None, + } + } + + #[test] + fn added_entries_get_new_ids_and_land_in_their_groups_made_once() { + let (_dir, store) = store(); + let saved = add( + &store, + vec![ + added(connection("a", "one"), &["Work", "Staging"]), + added(connection("b", "two"), &["work", "Staging"]), + ], + ); + let after = list(&store); + assert_eq!(after.groups.len(), 2); + assert_eq!(saved[0].group_id, saved[1].group_id); + let tree = after.group_tree(); + let leaf = saved[0].group_id.expect("a group"); + assert_eq!(tree.label(leaf).as_deref(), Some("Work / Staging")); + assert_eq!((saved[0].order, saved[1].order), (0, 1)); + } + + #[test] + fn a_group_path_past_the_depth_limit_is_cut() { + let (_dir, store) = store(); + let saved = add(&store, vec![added(connection("a", "one"), &["1", "2", "3", "4"])]); + let after = list(&store); + assert_eq!(after.groups.len(), ConnectionGroup::MAX_DEPTH); + let leaf = saved[0].group_id.expect("a group"); + assert_eq!(after.group_tree().label(leaf).as_deref(), Some("1 / 2 / 3")); + } + + #[test] + fn an_added_entry_never_takes_a_held_name() { + let (_dir, store) = store(); + let held = connection("prod", "one"); + store.upsert_blocking(held).expect("a save"); + let saved = add(&store, vec![added(connection("prod", "elsewhere"), &[])]); + assert_eq!(saved[0].name, "prod (imported)"); + } + + #[test] + fn a_replacement_keeps_the_id_only_when_it_connects_alike() { + let (_dir, store) = store(); + let mut held = connection("prod", "one"); + held.tags = Vec::new(); + held.color = Some(ConnectionColor::Red); + store.upsert_blocking(held.clone()).expect("a save"); + + let mut alike = connection("Production", "one"); + alike.color = Some(ConnectionColor::Blue); + let mut elsewhere = connection("prod copy", "another-host"); + elsewhere.id = held.id; + let saved = add( + &store, + vec![ + ImportedConnection { + connection: alike, + group_path: Vec::new(), + replaces: Some(held.id), + }, + ImportedConnection { + connection: elsewhere, + group_path: Vec::new(), + replaces: Some(held.id), + }, + ], + ); + assert_eq!(saved[0].id, held.id); + assert_eq!(saved[0].name, "Production"); + assert_eq!(saved[0].color, Some(ConnectionColor::Blue)); + assert_ne!(saved[1].id, held.id); + assert_eq!(list(&store).connections.len(), 2); + } + + #[test] + fn a_list_changed_since_the_review_is_refused() { + let (_dir, store) = store(); + let before = list(&store); + store.upsert_blocking(connection("meanwhile", "one")).expect("a save"); + let refused = store.add_imported_blocking(&before, vec![added(connection("a", "two"), &[])]); + assert!(matches!(refused, Err(StorageError::ImportOutdated))); + } +} diff --git a/core/crates/storage/src/connections/library/mod.rs b/core/crates/storage/src/connections/library/mod.rs index 45705621cb..9694da4880 100644 --- a/core/crates/storage/src/connections/library/mod.rs +++ b/core/crates/storage/src/connections/library/mod.rs @@ -205,7 +205,7 @@ impl ConnectionsDocument { .any(|group| group.parent_id == parent && !except.contains(&group.id) && group.name.same_name(name)) } - fn group_named(&self, parent: Option, name: &ConnectionGroupName) -> Option { + pub(super) fn group_named(&self, parent: Option, name: &ConnectionGroupName) -> Option { let siblings: Vec<&ConnectionGroup> = self.groups.iter().filter(|group| group.parent_id == parent).collect(); siblings .iter() diff --git a/core/crates/storage/src/connections/mod.rs b/core/crates/storage/src/connections/mod.rs index a2c4a4e5af..707528ca98 100644 --- a/core/crates/storage/src/connections/mod.rs +++ b/core/crates/storage/src/connections/mod.rs @@ -14,10 +14,13 @@ use crate::document_problem::{DocumentProblem, DocumentProblemKind}; use crate::error::StorageError; use crate::{ConnectionGroup, ConnectionTag, ConnectionTagName, GroupTree}; +mod additions; mod legacy_groups; mod library; mod placement; +pub use additions::ImportedConnection; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SavedConnection { pub id: Uuid, @@ -119,7 +122,7 @@ impl SavedConnection { /// Whether the two differ at most in how the list shows them. A /// field added later counts as part of the connection until it is /// listed here. - fn connects_like(&self, other: &Self) -> bool { + pub fn connects_like(&self, other: &Self) -> bool { let shown_alike = |saved: &Self| Self { name: String::new(), last_opened_at: None, diff --git a/core/crates/storage/src/lib.rs b/core/crates/storage/src/lib.rs index 8aeeb25933..33d1a59551 100644 --- a/core/crates/storage/src/lib.rs +++ b/core/crates/storage/src/lib.rs @@ -29,7 +29,7 @@ pub use connection_tag::ConnectionTag; pub use connection_tag_name::ConnectionTagName; pub use connections::{ ConnectionList, ConnectionListSnapshot, ConnectionListState, ConnectionStore, ImportChange, ImportEntry, - ImportPreview, ImportReport, RemoveOutcome, SavedConnection, SavedSshAuth, SavedSshConfig, + ImportPreview, ImportReport, ImportedConnection, RemoveOutcome, SavedConnection, SavedSshAuth, SavedSshConfig, }; pub use credentials::{ CredentialDemand, CredentialError, KeyringCredentials, KeyringError, ResolvedSecret, SecretItemLabeler, diff --git a/core/crates/storage/src/saved_queries/mod.rs b/core/crates/storage/src/saved_queries/mod.rs index ac1f2c329b..b0572de096 100644 --- a/core/crates/storage/src/saved_queries/mod.rs +++ b/core/crates/storage/src/saved_queries/mod.rs @@ -99,6 +99,48 @@ impl SavedQueries { Ok(SaveOutcome { id, replaced }) } + // An import never overwrites what the user saved, even when another window saves the same + // name between the import's look and its write. `None` when the name is taken. + pub async fn save_if_new(&self, query: NewSavedQuery) -> Result, StorageError> { + let name = query.name.trim(); + if name.is_empty() { + return Err(StorageError::EmptyName); + } + if name.chars().count() > MAX_NAME_CHARS { + return Err(StorageError::TooLarge { + got: name.chars().count(), + limit: MAX_NAME_CHARS, + }); + } + if query.query.len() > MAX_QUERY_BYTES { + return Err(StorageError::TooLarge { + got: query.query.len(), + limit: MAX_QUERY_BYTES, + }); + } + let now = to_unix(SystemTime::now()); + let inserted = sqlx::query( + r#" + INSERT INTO saved_queries (name, query, connection_id, connection_name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (connection_id, name) DO NOTHING + RETURNING id + "#, + ) + .bind(name) + .bind(&query.query) + .bind(query.connection_id.to_string()) + .bind(&query.connection_name) + .bind(now) + .bind(now) + .fetch_optional(&self.pool) + .await?; + Ok(match inserted { + Some(row) => Some(row.try_get("id")?), + None => None, + }) + } + /// Everything saved against `connection`, or everything at all when /// no connection is given. Ordered by name, because the user /// looking for one knows what they called it. @@ -244,6 +286,33 @@ mod tests { assert_eq!(saved.connection_name, "local"); } + #[tokio::test] + async fn saving_if_new_leaves_a_held_name_alone() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store(&root).await; + let connection = Uuid::new_v4(); + + let first = store + .save_if_new(new_query("daily report", "SELECT 1", connection)) + .await + .expect("save"); + let second = store + .save_if_new(new_query("daily report", "SELECT 2", connection)) + .await + .expect("save again"); + let elsewhere = store + .save_if_new(new_query("daily report", "SELECT 3", Uuid::new_v4())) + .await + .expect("save on another connection"); + + assert!(first.is_some()); + assert_eq!(second, None, "the held name was overwritten"); + assert!(elsewhere.is_some()); + let all = store.list(Some(connection)).await.expect("list"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].query, "SELECT 1"); + } + #[tokio::test] async fn saving_over_a_name_replaces_that_query_rather_than_adding_one() { let root = tempfile::tempdir().expect("tempdir"); diff --git a/core/docs/decisions/0007-importers-from-other-apps.md b/core/docs/decisions/0007-importers-from-other-apps.md new file mode 100644 index 0000000000..8618476e82 --- /dev/null +++ b/core/docs/decisions/0007-importers-from-other-apps.md @@ -0,0 +1,34 @@ +# 0007: Importers from other apps live in the core + +- **Status**: Accepted +- **Date**: 2026-11-03 + +## Context + +The macOS app imports saved connections and saved queries from six other database clients, in Swift under `apple/TablePro/Core/Services/Export/ForeignApp/`, with its review in `TableProImport`. The core had no importer, so the Linux app could not import from another app, and the Windows app offers only TablePro's own file until the importers reach the core. + +## Decision + +`tablepro-foreign-import` (`core/crates/foreign-import`) holds every importer, where each app keeps its files on macOS, Linux and Windows, and the review that maps what it read onto the core's connection library with the macOS duplicate handling. The macOS app keeps its Swift importers until it moves to the core. + +## Rationale + +- **One record shape.** An importer yields `ForeignConnection` records in the shape of the macOS app's exported connection: TablePro's engine names, the macOS SSL modes and colours. Each platform's library maps that one shape, so an importer is written once. +- **Platform services come in from the app.** `ForeignAppDirs` is a value: the home folder and the config and data directories, which a Flatpak build takes from `HOST_XDG_*`. `ForeignHost` answers whether an app is installed and hands over the keychain another app saved its passwords in. Linux and Windows pass `PlainHost`, which has neither. +- **Passwords.** `PasswordSource::AppFiles` apps keep them in their own files under a fixed key, which the core decrypts on every platform. `PasswordSource::Keychain` apps keep them in a keychain, which only macOS lets another app read, and only after the user allows it. A KeePass file an app keeps beside its settings is read on every platform. +- **Losses are said, not hidden.** `PasswordOutcome::LeftInKeychain` says passwords stayed in the other app's keychain, and `Stopped` that the user refused a keychain prompt or the store was locked. Each review row carries `RowNote`s: an engine no driver opens, a missing key or certificate file, an SSL mode the core maps to a stricter one (Preferred to Require, Verify CA to Verify Full), and every field it cannot keep. A saved query the core library cannot hold, such as one that belongs to no connection, gets a `QueryAvailability` that says why. +- **Duplicates as on macOS.** A row matches a saved connection by host, port, database and user, trimmed and ignoring case. It starts unchecked and offers Add as Copy, Replace only when the saved one connects the same way, and Keep Existing when the row brings saved queries. `ConnectionStore::add_imported_blocking` writes the plan only if the library is still the one reviewed, makes group paths in place, and never lets an entry take another's name. +- **Each importer owns its locations.** A `locations` module per app lists the places it searches on each platform, with the source that settles each one. Where an app can live in several places on Linux (the XDG directory, the default one, a Flatpak's or a Snap's copy), the copy changed last wins. + +## Consequences + +- TablePro's Flatpak cannot read another Flatpak's `~/.var/app` folder, which `--filesystem=home` leaves out, and adds no grant for it ([Linux ADR 0008](../../../linux/docs/decisions/0008-flatpak-sandbox-permissions.md)). Distribution packages read those folders. +- Some Windows passwords need host hooks the Windows app adds with its importer flow: DPAPI for a KeePass main key marked `CRYPT_32`, and Credential Manager for an app that keeps its key there. Until then the review reports those passwords as left behind. +- The core library keeps each saved query under one connection with no folder or keyword, so a query that belongs to no connection is listed and not imported, and folders and keywords are dropped. +- The core fixes several defects the Swift importers still have; the pull request that added this crate lists them. The macOS app takes the fixes when it moves to the core. + +## Alternatives considered + +- **A crate per app.** Six crates that share the record, the assembly, the SQL file walker and the review, for no boundary the apps need. +- **Reading other apps' keyrings in the core.** A platform service, and inside a Flatpak the Secret portal shows an app only its own items. +- **Flatpak grants for each app's `~/.var/app` folder.** Broader access for a one-time import, and a lint exception Flathub would have to grant per app. diff --git a/core/docs/decisions/README.md b/core/docs/decisions/README.md index fbb4c9e53c..c96dec3f19 100644 --- a/core/docs/decisions/README.md +++ b/core/docs/decisions/README.md @@ -14,3 +14,4 @@ The format, the statuses and how a later ADR changes an earlier one follow [the | [0004](0004-driver-plugin-abi.md) | Downloadable drivers over a small C ABI of our own | Accepted | A static descriptor, minor-version negotiation, protobuf messages, Arrow C Data rows, a runtime per driver and `catch_unwind` at every export; signed per platform, never unloaded, linked statically on iOS. | | [0005](0005-sql-server-transport.md) | SQL Server on tiberius everywhere, with Kerberos added and ODBC as a Windows option | Accepted | tiberius on every platform with SSPI Negotiate added in a fork, Entra ID tokens from the core, and an optional Windows connection type over an installed ODBC Driver 18 for Always Encrypted; msodbcsql18 is never bundled. | | [0006](0006-connection-library.md) | The connection library holds a group tree, manual order and favourites | Accepted | Groups form a tree by id, connections and groups carry an explicit order, favourites are one ordered list; version 2 group names become top-level groups. | +| [0007](0007-importers-from-other-apps.md) | Importers from other apps live in the core | Accepted | One crate reads each app's files on every platform into one record shape, takes keychains and directories from the app, says what it cannot carry, and reviews the result against the library with the macOS duplicate handling. | diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 12a8d950be..d57319dc5e 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -93,7 +93,7 @@ The `WorkspaceShell` (`crates/app/src/ui/workspace/workspace_shell.rs`) is what ## UI architecture -The application, its windows, the workspace and every view a rewrite has reached are gtk-rs subclasses. A widget with a layout has a CompositeTemplate `.ui` file under `data/resources/ui/`, compiled into the GResource: the session window and its pages, the workspace, the browse, structure and SQL editor tabs, the browse tab's filter strip and its rule rows, its pager (`PagerBar`) and its pending-changes bar (`PendingChangesBar`), the results pane, the SQL editor's find bar, the picker's welcome view, the query history dialog and its rows, the export operations button, and the connect and preferences dialogs with their sections. A subclass keeps its state in its `imp` struct and reports through signals. The window and the workspace take input through their input queues; a browse or structure tab handles its input in `send` at once, and one that arrives while another is being handled waits for it. +The application, its windows, the workspace and every view a rewrite has reached are gtk-rs subclasses. A widget with a layout has a CompositeTemplate `.ui` file under `data/resources/ui/`, compiled into the GResource: the session window and its pages, the workspace, the browse, structure and SQL editor tabs, the browse tab's filter strip and its rule rows, its pager (`PagerBar`) and its pending-changes bar (`PendingChangesBar`), the results pane, the SQL editor's find bar, the picker's welcome view, the query history dialog and its rows, the export operations button, the import from another app dialog, and the connect and preferences dialogs with their sections. A subclass keeps its state in its `imp` struct and reports through signals. The window and the workspace take input through their input queues; a browse or structure tab handles its input in `send` at once, and one that arrives while another is being handled waits for it. A few views are still [Relm4](https://relm4.org) components ([ADR 0003](docs/decisions/0003-relm4-architecture.md)): the saved-queries and CSV import dialogs. Their output reaches the workspace through `connect_receiver`, which puts it on the workspace's input queue. The two patterns coexist until a rewrite turns each component into a subclass. diff --git a/linux/build-aux/debian/copyright b/linux/build-aux/debian/copyright index c7c133a944..c2aba1eca8 100644 --- a/linux/build-aux/debian/copyright +++ b/linux/build-aux/debian/copyright @@ -679,6 +679,33 @@ License: CC0-1.0 On Debian systems, the complete text of the CC0 1.0 Universal license can be found in "/usr/share/common-licenses/CC0-1.0". +Files: vendor/adler2-2.0.1/* +Copyright: the authors of adler2 +License: 0BSD or Apache-2.0 or Expat + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + . + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + Files: vendor/aes-0.8.4/* Copyright: 2018 Artyom Pavlov License: Apache-2.0 or Expat @@ -1792,6 +1819,37 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/blowfish-0.9.1/* +Copyright: 2006-2009 Graydon Hoare + 2009-2013 Mozilla Foundation +License: Apache-2.0 or Expat + Copyright (c) 2006-2009 Graydon Hoare + Copyright (c) 2009-2013 Mozilla Foundation + . + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + . + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + Files: vendor/bnum-0.13.0/* Copyright: 2014 The Rust Project Developers 2018 Developers of the Rand project @@ -2165,6 +2223,35 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/chacha20-0.9.1/* +Copyright: 2019-2023 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2019-2023 The RustCrypto Project Developers + . + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + . + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + Files: vendor/chrono-0.4.45/* Copyright: 2014, Kang Seonghoon. License: Apache-2.0 or Expat @@ -6377,6 +6464,63 @@ License: Expat OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/miniz_oxide-0.9.1/* +Copyright: 2013-2014 RAD Game Tools and Valve Software + 2010-2014 Rich Geldreich and Tenacious Software LLC + 2017 Frommi + 2017-2024 oyvindln +License: Apache-2.0 or Expat or Zlib + MIT License + . + Copyright 2013-2014 RAD Game Tools and Valve Software + Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC + Copyright (c) 2017 Frommi + Copyright (c) 2017-2024 oyvindln + . + . + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + . + MIT License + . + Copyright 2013-2014 RAD Game Tools and Valve Software + Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC + Copyright (c) 2017 Frommi + Copyright (c) 2017-2024 oyvindln + . + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + Files: vendor/mio-1.2.3/* Copyright: 2014 Carl Lerche and other MIO contributors License: Expat @@ -6692,6 +6836,29 @@ License: Expat IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/plist-1.10.3/* +Copyright: 2015 Edward Barnard +License: Expat + Copyright (c) 2015 Edward Barnard + . + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + Files: vendor/postgres-protocol-0.6.12/* vendor/postgres-types-0.2.14/* vendor/tokio-postgres-0.7.18/* @@ -6832,6 +6999,33 @@ License: Expat OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/quick-xml-0.42.0/* +Copyright: 2016 Johann Tuffe +License: Expat + The MIT License (MIT) + . + Copyright (c) 2016 Johann Tuffe + . + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + . + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + . + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + Files: vendor/radium-0.7.0/* Copyright: 2019 kneecaw (Nika Layzell) License: Expat @@ -7063,6 +7257,31 @@ License: Expat OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/roxmltree-0.20.0/* +Copyright: 2018 Yevhenii Reizner +License: Apache-2.0 or Expat + The MIT License (MIT) + . + Copyright (c) 2018 Yevhenii Reizner + . + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + Files: vendor/rust_xlsxwriter-0.102.0/* Copyright: 2022-2026 John McNamara License: Apache-2.0 or Expat @@ -7196,6 +7415,35 @@ License: Apache-2.0 or BSL-1.0 On Debian systems, the complete text of the Apache License, Version 2.0 can be found in "/usr/share/common-licenses/Apache-2.0". +Files: vendor/salsa20-0.10.2/* +Copyright: 2019 Eric McCorkle +License: Apache-2.0 or Expat + Copyright (c) 2019 Eric McCorkle + . + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + . + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + Files: vendor/schemars-1.2.2/* Copyright: 2019 Graham Esau License: MIT diff --git a/linux/build-aux/rpm/tablepro.spec b/linux/build-aux/rpm/tablepro.spec index 9369b9814a..d8a97125c4 100644 --- a/linux/build-aux/rpm/tablepro.spec +++ b/linux/build-aux/rpm/tablepro.spec @@ -25,6 +25,7 @@ Summary: Work with your databases License: %{shrink: AGPL-3.0-or-later AND CC0-1.0 AND + (0BSD OR Apache-2.0 OR MIT) AND Apache-2.0 AND (Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT) AND (Apache-2.0 OR BSD-2-Clause OR MIT) AND diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 7136edc96c..8316f68202 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -69,6 +69,7 @@ tablepro-driver-mysql.workspace = true tablepro-driver-postgres.workspace = true tablepro-driver-redis.workspace = true tablepro-driver-sqlite.workspace = true +tablepro-foreign-import.workspace = true tablepro-mcp.workspace = true tablepro-net.workspace = true tablepro-linux-platform.workspace = true diff --git a/linux/crates/app/src/services/foreign_import.rs b/linux/crates/app/src/services/foreign_import.rs new file mode 100644 index 0000000000..874cccb005 --- /dev/null +++ b/linux/crates/app/src/services/foreign_import.rs @@ -0,0 +1,79 @@ +// The disk side of importing from another app, run off the GTK thread. + +use std::sync::Arc; + +use tablepro_core::DriverRegistry; +use tablepro_foreign_import::review::{ImportReview, ReviewInputs}; +use tablepro_foreign_import::{ + ForeignApp, ForeignAppDirs, ForeignImportError, ForeignImporter, ImportRequest, Inventory, PlainHost, +}; +use tablepro_storage::{ConnectionStore, StorageError}; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppSource { + pub app: ForeignApp, + pub available: bool, + pub inventory: Inventory, +} + +#[derive(Debug)] +pub(crate) enum ReadFailure { + Import(ForeignImportError), + Library(StorageError), +} + +pub(crate) fn importer(app: ForeignApp, dirs: &ForeignAppDirs) -> ForeignImporter { + ForeignImporter::new(app, dirs.clone(), Arc::new(PlainHost)) +} + +pub(crate) fn look_for_apps_blocking(dirs: &ForeignAppDirs) -> Vec { + ForeignApp::ALL + .into_iter() + .filter(|app| app.runs_on(dirs.platform)) + .map(|app| { + let importer = importer(app, dirs); + let available = importer.is_available_blocking(); + let inventory = if available && app.picked_file_extensions().is_none() { + importer.inventory_blocking() + } else { + Inventory::default() + }; + AppSource { + app, + available, + inventory, + } + }) + .collect() +} + +// The list read here is the one the review checks duplicates against and the write expects. +pub(crate) struct ReviewRead { + pub importer: ForeignImporter, + pub request: ImportRequest, + pub cancel: CancellationToken, + pub connections: ConnectionStore, + pub drivers: Arc, + pub dirs: ForeignAppDirs, +} + +impl ReviewRead { + pub(crate) fn run_blocking(&self) -> Result { + let collected = self + .importer + .collect_blocking(&self.request, &self.cancel) + .map_err(ReadFailure::Import)?; + let library = self.connections.list_blocking().map_err(ReadFailure::Library)?; + let file_exists = |path: &std::path::Path| path.exists(); + Ok(ImportReview::analyze( + collected, + ReviewInputs { + drivers: &self.drivers, + library, + dirs: &self.dirs, + file_exists: &file_exists, + }, + )) + } +} diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 2673053ec2..8a30b7e2bb 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -13,6 +13,7 @@ pub mod duplicate_name; pub mod file_location; mod file_location_kind; pub mod file_resolution; +pub(crate) mod foreign_import; pub mod grid; pub mod history_availability; pub mod history_preferences; diff --git a/linux/crates/app/src/services/saved_connection_editor.rs b/linux/crates/app/src/services/saved_connection_editor.rs index 325b74591a..c7cc9a8b8e 100644 --- a/linux/crates/app/src/services/saved_connection_editor.rs +++ b/linux/crates/app/src/services/saved_connection_editor.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use tablepro_core::SafeModeLevel; use tablepro_core::credentials::{SecretError, SecretKind, SecretLookup, SecretVault}; +use tablepro_foreign_import::review::{AppliedImport, ImportPlan, ImportReview, apply_blocking}; use tablepro_session::runtime::Tasks; use tablepro_storage::{ ConnectionColor, ConnectionGroupName, ConnectionIcon, ConnectionStore, ConnectionTag, ConnectionTagName, @@ -15,6 +16,13 @@ use super::secret_change::SecretChange; use super::secret_changes::SecretChanges; use super::secret_labels::secret_label; +// What an import from another app wrote: the connections, and how many of their secrets the +// keyring refused, which the user then types at connect. +pub struct ImportedFromApp { + pub applied: AppliedImport, + pub secrets_not_kept: usize, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum GroupChoice { None, @@ -211,6 +219,48 @@ impl SavedConnectionEditor { } } + // The list must still be the one reviewed; a refused secret leaves its connection saved. + pub async fn add_imported( + &self, + review: ImportReview, + plan: ImportPlan, + ) -> Result { + let _turn = self.turn.lock().await; + let store = self.store.clone(); + let applied = self + .tasks + .spawn_blocking_task(move || apply_blocking(&review, &plan, &store)) + .await??; + let mut secrets_not_kept = 0; + for imported in &applied.connections { + for secret in &imported.secrets { + let label = secret_label(secret.kind, &imported.connection); + if let Err(error) = self + .secrets + .store(imported.connection.id, secret.kind, &secret.secret, &label) + .await + { + tracing::warn!(%error, kind = ?secret.kind, "a secret of an imported connection was not kept"); + secrets_not_kept += 1; + } + } + } + // A delete from another window can land between the write and the keyring, and its own + // clean-up ran before these secrets existed. + if let Ok(list) = self.list().await { + for imported in &applied.connections { + let id = imported.connection.id; + if !imported.secrets.is_empty() && !list.iter().any(|saved| saved.id == id) { + self.forget_secrets(id).await; + } + } + } + Ok(ImportedFromApp { + applied, + secrets_not_kept, + }) + } + /// A pick of the group it is already in writes nothing. pub async fn move_to_group(&self, id: Uuid, choice: GroupChoice) -> Result<(), SavedConnectionError> { let store = self.store.clone(); diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index 4c91921fcb..799e5a1233 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -44,6 +44,7 @@ pub use self::form_errors::FormErrors; pub use self::form_field::FormField; use self::pem_validation::{PemState, PemValidation}; pub use self::row_problem::show_problem; +pub(crate) use self::tls_section::mode_label as tls_mode_label; use crate::services::saved_connection_editor::SavedConnectionEditor; use crate::services::secret_changes::SecretChanges; use crate::services::session_context::CredentialSourceFactory; diff --git a/linux/crates/app/src/ui/import_from_app/imp.rs b/linux/crates/app/src/ui/import_from_app/imp.rs new file mode 100644 index 0000000000..936bdd6aeb --- /dev/null +++ b/linux/crates/app/src/ui/import_from_app/imp.rs @@ -0,0 +1,67 @@ +use std::cell::{OnceCell, RefCell}; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::{DialogParts, Reviewing, SourceRow}; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/import-from-app-dialog.ui")] +pub struct ImportFromAppDialog { + #[template_child] + pub navigation: TemplateChild, + #[template_child] + pub continue_button: TemplateChild, + #[template_child] + pub sources_stack: TemplateChild, + #[template_child] + pub apps_group: TemplateChild, + #[template_child] + pub passwords_row: TemplateChild, + #[template_child] + pub queries_row: TemplateChild, + #[template_child] + pub reading_status: TemplateChild, + #[template_child] + pub failed_status: TemplateChild, + #[template_child] + pub review_page: TemplateChild, + #[template_child] + pub passwords_banner: TemplateChild, + #[template_child] + pub review_preferences: TemplateChild, + #[template_child] + pub connections_group: TemplateChild, + #[template_child] + pub queries_group: TemplateChild, + #[template_child] + pub summary_label: TemplateChild, + #[template_child] + pub import_button: TemplateChild, + + pub(super) parts: OnceCell, + pub(super) sources: RefCell>, + pub(super) reviewing: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for ImportFromAppDialog { + const NAME: &'static str = "TableProImportFromAppDialog"; + type Type = super::ImportFromAppDialog; + type ParentType = adw::Dialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for ImportFromAppDialog {} +impl WidgetImpl for ImportFromAppDialog {} +impl AdwDialogImpl for ImportFromAppDialog {} diff --git a/linux/crates/app/src/ui/import_from_app/mod.rs b/linux/crates/app/src/ui/import_from_app/mod.rs new file mode 100644 index 0000000000..d302e1e640 --- /dev/null +++ b/linux/crates/app/src/ui/import_from_app/mod.rs @@ -0,0 +1,669 @@ +// Import from another app: pick the app, read its connections off the GTK thread, review them +// against the saved ones with the macOS duplicate handling, then hand the plan to the window. + +mod imp; +#[cfg(test)] +mod tests; +mod wording; + +use std::cell::{Cell, RefCell}; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::DriverRegistry; +use tablepro_foreign_import::review::{ + ConnectionRow, ImportPlan, ImportReview, ImportSelection, QueryAvailability, QueryRow, Resolution, plan, +}; +use tablepro_foreign_import::{ForeignApp, ForeignAppDirs, ForeignImporter, ImportRequest, PasswordSource}; +use tablepro_session::runtime::Tasks; +use tablepro_storage::ConnectionStore; +use tokio_util::sync::CancellationToken; + +use crate::i18n::{gettext, gettext_f, ngettext_f}; +use crate::runtime::spawn_and_deliver; +use crate::services::foreign_import::{AppSource, ReadFailure, ReviewRead, importer, look_for_apps_blocking}; + +// The core library's cap on one saved query. +const SAVED_QUERY_BYTE_LIMIT: u64 = 1024 * 1024; + +glib::wrapper! { + pub struct ImportFromAppDialog(ObjectSubclass) + @extends adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub(crate) type ImportRequested = Box; + +pub(crate) struct ImportFromAppInit { + pub dirs: ForeignAppDirs, + pub drivers: Arc, + pub connections: ConnectionStore, + pub tasks: Tasks, + pub on_import: ImportRequested, +} + +pub(super) struct DialogParts { + dirs: ForeignAppDirs, + drivers: Arc, + connections: ConnectionStore, + tasks: Tasks, + on_import: ImportRequested, + cancel: CancellationToken, + // The switches' own answers, kept while an app that cannot honour one is picked. + wants_passwords: Cell, + wants_queries: Cell, +} + +pub(super) struct SourceRow { + app: ForeignApp, + check: gtk::CheckButton, + available: bool, +} + +pub(super) struct Reviewing { + review: ImportReview, + selection: ImportSelection, + connections: Vec, + queries: Vec, +} + +struct ConnectionWidgets { + reference: String, + row: adw::ActionRow, + check: gtk::CheckButton, + resolution: Option, + // What the drop-down lists, which a Replace another row takes changes. + offered: RefCell>, +} + +#[derive(Clone)] +struct QueryWidgets { + check: gtk::CheckButton, + row: adw::ActionRow, +} + +impl ImportFromAppDialog { + pub(crate) fn new(init: ImportFromAppInit) -> Self { + let dialog: Self = glib::Object::new(); + let imp = dialog.imp(); + let _ = imp.parts.set(DialogParts { + dirs: init.dirs, + drivers: init.drivers, + connections: init.connections, + tasks: init.tasks, + on_import: init.on_import, + cancel: CancellationToken::new(), + wants_passwords: Cell::new(true), + wants_queries: Cell::new(true), + }); + imp.sources_stack.set_visible_child_name("loading"); + dialog.connect_signals(); + dialog.load_sources(); + dialog + } + + fn parts(&self) -> Option<&DialogParts> { + self.imp().parts.get() + } + + fn connect_signals(&self) { + let imp = self.imp(); + imp.continue_button.connect_clicked(glib::clone!( + #[weak(rename_to = dialog)] + self, + move |_| dialog.on_continue() + )); + imp.import_button.connect_clicked(glib::clone!( + #[weak(rename_to = dialog)] + self, + move |_| dialog.on_import() + )); + imp.passwords_row.connect_active_notify(glib::clone!( + #[weak(rename_to = dialog)] + self, + move |row| { + if let Some(parts) = dialog.parts().filter(|_| row.is_sensitive()) { + parts.wants_passwords.set(row.is_active()); + } + } + )); + imp.queries_row.connect_active_notify(glib::clone!( + #[weak(rename_to = dialog)] + self, + move |row| { + if let Some(parts) = dialog.parts().filter(|_| row.is_sensitive()) { + parts.wants_queries.set(row.is_active()); + } + } + )); + self.connect_closed(|dialog| { + if let Some(parts) = dialog.parts() { + parts.cancel.cancel(); + } + }); + } + + fn load_sources(&self) { + let Some(parts) = self.parts() else { + return; + }; + let dirs = parts.dirs.clone(); + let blocking = parts.tasks.clone(); + let dialog = self.downgrade(); + spawn_and_deliver( + &parts.tasks, + async move { + blocking + .spawn_blocking_task(move || look_for_apps_blocking(&dirs)) + .await + }, + move |outcome| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + match outcome { + Ok(Ok(sources)) => dialog.show_sources(&sources), + Ok(Err(failure)) | Err(failure) => { + tracing::warn!(%failure, "could not look for other apps"); + dialog.show_sources(&[]); + } + } + }, + ); + } + + fn show_sources(&self, sources: &[AppSource]) { + let imp = self.imp(); + let mut rows = Vec::with_capacity(sources.len()); + let mut leader: Option = None; + for source in sources { + let check = gtk::CheckButton::builder().valign(gtk::Align::Center).build(); + if let Some(leader) = &leader { + check.set_group(Some(leader)); + } else { + leader = Some(check.clone()); + } + let row = adw::ActionRow::builder() + .title(source.app.display_name()) + .subtitle(wording::source_subtitle(source.app, source.available, source.inventory)) + .use_markup(false) + .activatable_widget(&check) + .sensitive(source.available) + .build(); + row.add_prefix(&check); + check.connect_toggled(glib::clone!( + #[weak(rename_to = dialog)] + self, + move |check| { + if check.is_active() { + dialog.update_options(); + } + } + )); + imp.apps_group.add(&row); + rows.push(SourceRow { + app: source.app, + check, + available: source.available, + }); + } + if let Some(first) = rows.iter().find(|row| row.available) { + first.check.set_active(true); + } + imp.sources.replace(rows); + imp.sources_stack.set_visible_child_name("sources"); + self.update_options(); + } + + fn selected_app(&self) -> Option { + self.imp() + .sources + .borrow() + .iter() + .find(|row| row.available && row.check.is_active()) + .map(|row| row.app) + } + + fn update_options(&self) { + let imp = self.imp(); + let app = self.selected_app(); + imp.continue_button.set_sensitive(app.is_some()); + let (Some(app), Some(parts)) = (app, self.parts()) else { + imp.passwords_row.set_sensitive(false); + imp.queries_row.set_sensitive(false); + return; + }; + let passwords = wording::password_option(app); + imp.passwords_row.set_sensitive(passwords.offered); + imp.passwords_row + .set_active(passwords.offered && parts.wants_passwords.get()); + imp.passwords_row.set_subtitle(&passwords.subtitle); + let queries = wording::saved_query_option(app); + imp.queries_row.set_sensitive(queries.offered); + imp.queries_row.set_active(queries.offered && parts.wants_queries.get()); + imp.queries_row.set_subtitle(&queries.subtitle); + } + + fn request(&self, app: ForeignApp) -> ImportRequest { + let imp = self.imp(); + // With the switch unavailable, a keychain app's passwords are still asked for, so the + // review says they stayed behind. + let include_passwords = if imp.passwords_row.is_sensitive() { + imp.passwords_row.is_active() + } else { + app.password_source() == PasswordSource::Keychain + }; + ImportRequest { + include_passwords, + include_saved_queries: imp.queries_row.is_sensitive() && imp.queries_row.is_active(), + saved_query_byte_limit: SAVED_QUERY_BYTE_LIMIT, + } + } + + fn on_continue(&self) { + let (Some(app), Some(parts)) = (self.selected_app(), self.parts()) else { + return; + }; + let importer = importer(app, &parts.dirs); + match app.picked_file_extensions() { + Some(extensions) => self.pick_file(importer, extensions), + None => self.collect(importer), + } + } + + fn pick_file(&self, importer: ForeignImporter, extensions: &[&str]) { + let filter = gtk::FileFilter::new(); + filter.set_name(Some(&gettext_f( + "{app} export files", + &[("app", importer.app().display_name())], + ))); + for extension in extensions { + filter.add_suffix(extension); + } + let filters = gio::ListStore::new::(); + filters.append(&filter); + let file_dialog = gtk::FileDialog::builder() + .title(gettext_f( + "Choose a {app} Export File", + &[("app", importer.app().display_name())], + )) + .modal(true) + .default_filter(&filter) + .filters(&filters) + .build(); + let window = self.root().and_downcast::(); + let dialog = self.downgrade(); + file_dialog.open(window.as_ref(), gio::Cancellable::NONE, move |picked| { + let (Some(dialog), Ok(file)) = (dialog.upgrade(), picked) else { + return; + }; + match file.path() { + Some(path) => dialog.collect(importer.with_file(path)), + None => dialog.show_failure(&gettext("Only files on this computer can be imported.")), + } + }); + } + + fn collect(&self, importer: ForeignImporter) { + let imp = self.imp(); + let Some(parts) = self.parts() else { + return; + }; + let app = importer.app(); + imp.reading_status.set_description(Some(&gettext_f( + "Reading connections from {app}…", + &[("app", app.display_name())], + ))); + imp.navigation.push_by_tag("reading"); + let read = ReviewRead { + importer, + request: self.request(app), + cancel: parts.cancel.clone(), + connections: parts.connections.clone(), + drivers: parts.drivers.clone(), + dirs: parts.dirs.clone(), + }; + let blocking = parts.tasks.clone(); + let dialog = self.downgrade(); + spawn_and_deliver( + &parts.tasks, + async move { blocking.spawn_blocking_task(move || read.run_blocking()).await }, + move |outcome| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + match outcome { + Ok(Ok(Ok(review))) => dialog.show_review(review), + Ok(Ok(Err(ReadFailure::Import(error)))) => { + dialog.show_failure(&wording::collect_failure(app, &error)) + } + Ok(Ok(Err(ReadFailure::Library(error)))) => { + tracing::warn!(%error, "could not read the saved connections to compare"); + dialog.show_failure(&gettext( + "Your saved connections could not be read, so nothing was imported.", + )); + } + Ok(Err(failure)) | Err(failure) => { + tracing::warn!(%failure, "the import read did not finish"); + dialog.show_failure(&gettext("The connections could not be read.")); + } + } + }, + ); + } + + fn show_failure(&self, message: &str) { + let imp = self.imp(); + if self.parts().is_none_or(|parts| parts.cancel.is_cancelled()) { + return; + } + imp.failed_status.set_description(Some(message)); + imp.navigation.replace_with_tags(&["sources", "failed"]); + } + + fn show_review(&self, review: ImportReview) { + let imp = self.imp(); + let Some(parts) = self.parts().filter(|parts| !parts.cancel.is_cancelled()) else { + return; + }; + let app = review.app(); + imp.review_page + .set_title(&gettext_f("Import from {app}", &[("app", app.display_name())])); + match wording::password_banner(app, review.passwords()) { + Some(text) => { + imp.passwords_banner.set_title(&text); + imp.passwords_banner.set_revealed(true); + } + None => imp.passwords_banner.set_revealed(false), + } + if let Some(previous) = imp.reviewing.take() { + for widgets in &previous.connections { + imp.connections_group.remove(&widgets.row); + } + for widgets in &previous.queries { + imp.queries_group.remove(&widgets.row); + } + } + + let selection = ImportSelection::defaults(&review); + let connections = review + .connections() + .iter() + .map(|row| self.connection_row(row, &selection, &parts.drivers)) + .collect(); + let queries: Vec = review + .saved_queries() + .iter() + .map(|row| self.query_row(row, &selection)) + .collect(); + imp.queries_group.set_visible(!queries.is_empty()); + imp.reviewing.replace(Some(Reviewing { + review, + selection, + connections, + queries, + })); + self.refresh_review(); + imp.navigation.replace_with_tags(&["sources", "review"]); + } + + fn connection_row( + &self, + row: &ConnectionRow, + selection: &ImportSelection, + drivers: &DriverRegistry, + ) -> ConnectionWidgets { + let imp = self.imp(); + let selectable = row.connection.is_some(); + let check = gtk::CheckButton::builder() + .valign(gtk::Align::Center) + .active(selection.is_selected(&row.reference)) + .sensitive(selectable) + .build(); + let widget = adw::ActionRow::builder() + .title(&row.name) + .subtitle(wording::connection_subtitle(row, drivers)) + .subtitle_lines(0) + .use_markup(false) + .activatable_widget(&check) + .sensitive(selectable) + .build(); + widget.add_prefix(&check); + let offered = selection.offered_resolutions(row); + let resolution = row.duplicate.as_ref().map(|_| { + let drop_down = gtk::DropDown::builder().model(&resolution_list(&offered)).build(); + drop_down.set_valign(gtk::Align::Center); + drop_down.set_tooltip_text(Some(&gettext("If the connection is already there"))); + drop_down.update_property(&[gtk::accessible::Property::Label(&gettext( + "If the connection is already there", + ))]); + let chosen = selection + .resolution(&row.reference) + .or_else(|| offered.first().copied()); + if let Some(index) = chosen.and_then(|choice| offered.iter().position(|offer| *offer == choice)) { + drop_down.set_selected(u32::try_from(index).unwrap_or(0)); + } + widget.add_suffix(&drop_down); + let reference = row.reference.clone(); + drop_down.connect_selected_notify(glib::clone!( + #[weak(rename_to = dialog)] + self, + move |drop_down| dialog.on_resolution_changed(&reference, drop_down.selected()) + )); + drop_down + }); + let reference = row.reference.clone(); + check.connect_toggled(glib::clone!( + #[weak(rename_to = dialog)] + self, + move |check| dialog.on_connection_toggled(&reference, check.is_active()) + )); + imp.connections_group.add(&widget); + ConnectionWidgets { + reference: row.reference.clone(), + row: widget, + check, + resolution, + offered: RefCell::new(offered), + } + } + + fn query_row(&self, row: &QueryRow, selection: &ImportSelection) -> QueryWidgets { + let check = gtk::CheckButton::builder() + .valign(gtk::Align::Center) + .active(selection.wants_query(row)) + .build(); + let widget = adw::ActionRow::builder() + .title(&row.name) + .use_markup(false) + .activatable_widget(&check) + .build(); + widget.add_prefix(&check); + let reference = row.reference.clone(); + check.connect_toggled(glib::clone!( + #[weak(rename_to = dialog)] + self, + move |check| dialog.on_query_toggled(&reference, check.is_active()) + )); + self.imp().queries_group.add(&widget); + QueryWidgets { check, row: widget } + } + + // A refresh sets widgets while it holds the review, and their signals land here: those are skipped. + fn on_connection_toggled(&self, reference: &str, selected: bool) { + { + let Ok(mut reviewing) = self.imp().reviewing.try_borrow_mut() else { + return; + }; + let Some(reviewing) = reviewing.as_mut() else { + return; + }; + if reviewing.selection.is_selected(reference) != selected { + let Reviewing { review, selection, .. } = reviewing; + selection.set_selected(selected, reference, review); + } + } + self.refresh_review(); + } + + fn on_resolution_changed(&self, reference: &str, index: u32) { + { + let Ok(mut reviewing) = self.imp().reviewing.try_borrow_mut() else { + return; + }; + let Some(reviewing) = reviewing.as_mut() else { + return; + }; + let Some(choice) = reviewing + .connections + .iter() + .find(|widgets| widgets.reference == reference) + .and_then(|widgets| { + widgets + .offered + .borrow() + .get(usize::try_from(index).unwrap_or(usize::MAX)) + .copied() + }) + else { + return; + }; + if reviewing.selection.resolution(reference) != Some(choice) { + let Reviewing { review, selection, .. } = reviewing; + selection.resolve(reference, choice, review); + } + } + self.refresh_review(); + } + + fn on_query_toggled(&self, reference: &str, included: bool) { + { + let Ok(mut reviewing) = self.imp().reviewing.try_borrow_mut() else { + return; + }; + let Some(reviewing) = reviewing.as_mut() else { + return; + }; + let Some(row) = reviewing + .review + .saved_queries() + .iter() + .find(|row| row.reference == reference) + else { + return; + }; + if reviewing.selection.wants_query(row) != included { + reviewing.selection.set_included(included, reference); + } + } + // Even an unchanged choice is drawn again, so a box always shows what the plan imports. + self.refresh_review(); + } + + // Each change can move another row: a Replace one row takes is withdrawn from the others, and a + // query follows whether its connection imports. + fn refresh_review(&self) { + let imp = self.imp(); + let reviewing = imp.reviewing.borrow(); + let Some(reviewing) = reviewing.as_ref() else { + return; + }; + let planned = plan(&reviewing.review, &reviewing.selection); + for (widgets, row) in reviewing.connections.iter().zip(reviewing.review.connections()) { + let selected = reviewing.selection.is_selected(&row.reference); + if widgets.check.is_active() != selected { + widgets.check.set_active(selected); + } + if let Some(drop_down) = &widgets.resolution { + let offered = reviewing.selection.offered_resolutions(row); + if *widgets.offered.borrow() != offered { + drop_down.set_model(Some(&resolution_list(&offered))); + widgets.offered.replace(offered.clone()); + } + let current = reviewing.selection.resolution(&row.reference); + if let Some(index) = current + .and_then(|choice| offered.iter().position(|offer| *offer == choice)) + .and_then(|index| u32::try_from(index).ok()) + .filter(|index| drop_down.selected() != *index) + { + drop_down.set_selected(index); + } + drop_down.set_sensitive(selected && offered.len() > 1); + drop_down.set_visible(selected); + } + } + for (widgets, row) in reviewing.queries.iter().zip(reviewing.review.saved_queries()) { + let status = planned.query_status(&row.reference); + // A name another row takes on the same connection waits until that row is unchecked. + let importable = status.is_some_and(|status| status.availability == QueryAvailability::Available); + let included = status.is_some_and(|status| status.included); + widgets.row.set_sensitive(importable); + if widgets.check.is_active() != included { + widgets.check.set_active(included); + } + widgets.row.set_subtitle(&wording::query_subtitle(row, status)); + } + let connections = reviewing.review.connections(); + let selected = connections + .iter() + .filter(|row| reviewing.selection.is_selected(&row.reference)) + .count(); + let mut summary = ngettext_f( + "{selected} of {n} connection selected", + "{selected} of {n} connections selected", + u32::try_from(connections.len()).unwrap_or(u32::MAX), + &[ + ("selected", &selected.to_string()), + ("n", &connections.len().to_string()), + ], + ); + if !reviewing.queries.is_empty() { + let included = planned + .query_statuses() + .values() + .filter(|status| status.included) + .count(); + summary.push('\n'); + summary.push_str(&ngettext_f( + "{included} of {n} saved query selected", + "{included} of {n} saved queries selected", + u32::try_from(reviewing.queries.len()).unwrap_or(u32::MAX), + &[ + ("included", &included.to_string()), + ("n", &reviewing.queries.len().to_string()), + ], + )); + } + imp.summary_label.set_label(&summary); + imp.import_button.set_sensitive(!planned.is_empty()); + } + + fn on_import(&self) { + let Some(reviewing) = self.imp().reviewing.take() else { + return; + }; + let planned = plan(&reviewing.review, &reviewing.selection); + if planned.is_empty() { + self.imp().reviewing.replace(Some(reviewing)); + return; + } + if let Some(parts) = self.parts() { + (parts.on_import)(reviewing.review, planned); + } + self.close(); + } +} + +fn resolution_list(offered: &[Resolution]) -> gtk::StringList { + let titles: Vec = offered + .iter() + .map(|choice| wording::resolution_title(*choice)) + .collect(); + let titles: Vec<&str> = titles.iter().map(String::as_str).collect(); + gtk::StringList::new(&titles) +} diff --git a/linux/crates/app/src/ui/import_from_app/tests.rs b/linux/crates/app/src/ui/import_from_app/tests.rs new file mode 100644 index 0000000000..9b77e73f9b --- /dev/null +++ b/linux/crates/app/src/ui/import_from_app/tests.rs @@ -0,0 +1,267 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use tablepro_core::{AiPolicy, AuthMode, ExternalAccess, SafeModeLevel, TlsConfig}; +use tablepro_foreign_import::ForeignPlatform; +use tablepro_storage::{SavedConnection, StoragePaths}; +use uuid::Uuid; + +use super::*; +use crate::test_support::{test_runtime, wait_until, write_text_file}; + +const WAIT: Duration = Duration::from_secs(30); + +const DATA_SOURCES: &str = r#"{ + "folders": {"Clients": {}}, + "connections": { + "postgres-orders": { + "provider": "postgresql", "name": "orders-prod", "folder": "Clients", + "configuration": {"host": "db.acme.example", "port": "5432", "database": "orders", "user": "app"} + }, + "mysql-billing": { + "provider": "mysql", "name": "billing", + "configuration": {"host": "billing.acme.example", "port": "3306", "database": "billing", "user": "deploy"} + }, + "oracle-ledger": { + "provider": "oracle", "name": "ledger", + "configuration": {"host": "ora.acme.example", "port": "1521", "database": "LEDGER", "user": "fin"} + } + } +}"#; + +// Two scripts of one name for one connection: the library keeps one query per name there. +const PROJECT_METADATA: &str = r#"{"resources": { + "Scripts/a/Report.sql": {"default-datasource": "mysql-billing"}, + "Scripts/b/Report.sql": {"default-datasource": "mysql-billing"} +}}"#; + +struct Harness { + _runtime: crate::runtime::AppRuntime, + _root: tempfile::TempDir, + store: ConnectionStore, + dialog: ImportFromAppDialog, + imported: Rc>>, + _window: adw::Window, +} + +fn drivers() -> Arc { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(tablepro_driver_postgres::PgDriver)); + drivers.register(Arc::new(tablepro_driver_mysql::MysqlDriver)); + Arc::new(drivers) +} + +fn saved_orders() -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "orders".to_owned(), + driver_id: "postgres".to_owned(), + host: "db.acme.example".to_owned(), + port: 5432, + database: "orders".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + safe_mode: SafeModeLevel::Off, + external_access: ExternalAccess::ReadOnly, + ai_policy: AiPolicy::Ask, + query_timeout_secs: None, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group_id: None, + order: 0, + tags: Vec::new(), + icon: None, + options: Default::default(), + } +} + +impl Harness { + fn new() -> Self { + crate::register_test_resources(); + let runtime = test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let home = root.path().join("home"); + let dirs = ForeignAppDirs::conventional(ForeignPlatform::Linux, &home); + let project = dirs.data.join("DBeaverData/workspace6/General/.dbeaver"); + std::fs::create_dir_all(&project).expect("the DBeaver project"); + write_text_file(&project.join("data-sources.json"), DATA_SOURCES); + write_text_file(&project.join("project-metadata.json"), PROJECT_METADATA); + let scripts = project.parent().expect("the project folder").join("Scripts"); + for folder in ["a", "b"] { + std::fs::create_dir_all(scripts.join(folder)).expect("a scripts folder"); + write_text_file(&scripts.join(folder).join("Report.sql"), &format!("SELECT '{folder}'")); + } + + let paths = StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + let store = ConnectionStore::new(&paths); + store.upsert_blocking(saved_orders()).expect("a saved connection"); + + let imported = Rc::new(RefCell::new(None)); + let sink = imported.clone(); + let dialog = ImportFromAppDialog::new(ImportFromAppInit { + dirs, + drivers: drivers(), + connections: store.clone(), + tasks: runtime.tasks(), + on_import: Box::new(move |review, plan| { + sink.replace(Some((review, plan))); + }), + }); + let window = adw::Window::new(); + window.set_default_size(800, 800); + window.present(); + dialog.present(Some(&window)); + Self { + _runtime: runtime, + _root: root, + store, + dialog, + imported, + _window: window, + } + } + + fn source(&self, app: ForeignApp) -> Option<(bool, gtk::CheckButton)> { + self.dialog + .imp() + .sources + .borrow() + .iter() + .find(|row| row.app == app) + .map(|row| (row.available, row.check.clone())) + } + + fn sources_shown(&self) -> bool { + self.dialog.imp().sources_stack.visible_child_name().as_deref() == Some("sources") + } + + fn page(&self) -> Option { + self.dialog + .imp() + .navigation + .visible_page() + .and_then(|page| page.tag()) + .map(|tag| tag.to_string()) + } +} + +#[gtk4::test] +fn an_app_found_on_this_computer_is_read_into_a_review_with_the_duplicate_unchecked() { + let harness = Harness::new(); + wait_until(WAIT, || harness.sources_shown()).expect("the apps to be looked for"); + + assert!( + harness.source(ForeignApp::SequelAce).is_none(), + "an app that runs only on macOS is listed" + ); + let (available, check) = harness.source(ForeignApp::DBeaver).expect("the DBeaver row"); + assert!(available); + assert!(check.is_active(), "the first app found is not picked"); + let (navicat_available, _) = harness.source(ForeignApp::Navicat).expect("the Navicat row"); + assert!(navicat_available, "an app read from an export file is always offered"); + assert!(harness.dialog.imp().continue_button.is_sensitive()); + + harness.dialog.imp().continue_button.emit_clicked(); + wait_until(WAIT, || harness.page().as_deref() == Some("review")).expect("the review"); + + let imp = harness.dialog.imp(); + { + let reviewing = imp.reviewing.borrow(); + let reviewing = reviewing.as_ref().expect("a review"); + let names: Vec<&str> = reviewing + .review + .connections() + .iter() + .map(|row| row.name.as_str()) + .collect(); + assert_eq!(names, ["billing", "ledger", "orders-prod"]); + let checked: Vec = reviewing + .connections + .iter() + .map(|widgets| widgets.check.is_active()) + .collect(); + // The duplicate of a saved connection and the engine with no driver start unchecked. + assert_eq!(checked, [true, false, false]); + assert!( + !reviewing.connections[1].check.is_sensitive(), + "a connection no driver opens can be picked" + ); + let duplicate = reviewing.connections[2] + .resolution + .as_ref() + .expect("a choice for the duplicate"); + assert!( + !duplicate.is_visible(), + "the duplicate's choice shows while it is unchecked" + ); + } + assert!(imp.import_button.is_sensitive()); +} + +#[gtk4::test] +fn checking_a_duplicate_offers_its_choice_and_import_hands_over_the_plan() { + let harness = Harness::new(); + wait_until(WAIT, || harness.sources_shown()).expect("the apps to be looked for"); + harness.dialog.imp().continue_button.emit_clicked(); + wait_until(WAIT, || harness.page().as_deref() == Some("review")).expect("the review"); + + let duplicate = { + let reviewing = harness.dialog.imp().reviewing.borrow(); + let reviewing = reviewing.as_ref().expect("a review"); + ( + reviewing.connections[2].check.clone(), + reviewing.connections[2].resolution.clone().expect("a choice"), + ) + }; + duplicate.0.set_active(true); + assert!(duplicate.1.is_visible()); + // The saved one verifies TLS and this one does not, so only a copy is offered, never Replace. + assert_eq!(duplicate.1.model().map(|model| model.n_items()), Some(1)); + assert!(!duplicate.1.is_sensitive()); + + harness.dialog.imp().import_button.emit_clicked(); + let imported = harness.imported.borrow(); + let (_, plan) = imported.as_ref().expect("the plan handed over"); + let planned: Vec<&str> = plan + .connections() + .iter() + .map(|planned| planned.connection.name.as_str()) + .collect(); + assert_eq!(planned, ["billing", "orders-prod"]); + assert!(plan.connections().iter().all(|planned| planned.replaces.is_none())); + assert_eq!(harness.store.list_blocking().expect("the list").connections.len(), 1); +} + +#[gtk4::test] +fn a_query_whose_name_another_row_takes_stays_unchecked_when_clicked() { + let harness = Harness::new(); + wait_until(WAIT, || harness.sources_shown()).expect("the apps to be looked for"); + harness.dialog.imp().continue_button.emit_clicked(); + wait_until(WAIT, || harness.page().as_deref() == Some("review")).expect("the review"); + + let (first, second) = { + let reviewing = harness.dialog.imp().reviewing.borrow(); + let reviewing = reviewing.as_ref().expect("a review"); + assert_eq!(reviewing.queries.len(), 2); + (reviewing.queries[0].clone(), reviewing.queries[1].clone()) + }; + assert!(first.check.is_active()); + assert!(!second.check.is_active()); + assert!( + !second.row.is_sensitive(), + "a query the plan cannot take can be checked" + ); + + second.check.set_active(true); + assert!(!second.check.is_active(), "the box shows a query the plan leaves out"); + + first.check.set_active(false); + assert!( + second.check.is_active(), + "the second query does not take the name once the first is unchecked" + ); + assert!(second.row.is_sensitive()); +} diff --git a/linux/crates/app/src/ui/import_from_app/wording.rs b/linux/crates/app/src/ui/import_from_app/wording.rs new file mode 100644 index 0000000000..9057333a8e --- /dev/null +++ b/linux/crates/app/src/ui/import_from_app/wording.rs @@ -0,0 +1,257 @@ +use tablepro_core::{DriverRegistry, TlsMode}; +use tablepro_foreign_import::review::{ + ConnectionRow, QueryAvailability, QueryRow, QueryStatus, Resolution, RowNote, TlsFileReason, +}; +use tablepro_foreign_import::{ + ForeignApp, ForeignImportError, ForeignSslMode, Inventory, PasswordOutcome, PasswordSource, SavedQuerySupport, +}; +use tablepro_storage::GroupTree; + +use crate::i18n::{gettext, gettext_f, ngettext_f}; +use crate::services::saved_connection_object::subtitle_for; + +pub(super) struct OptionText { + pub offered: bool, + pub subtitle: String, +} + +pub(super) fn source_subtitle(app: ForeignApp, available: bool, inventory: Inventory) -> String { + if app.picked_file_extensions().is_some() { + return gettext("Choose an export file to import"); + } + if !available { + return gettext("Not found on this computer"); + } + let connections = ngettext_f( + "{n} connection found", + "{n} connections found", + count(inventory.connections), + &[("n", &inventory.connections.to_string())], + ); + if inventory.saved_queries == 0 { + return connections; + } + let queries = ngettext_f( + "{n} saved query", + "{n} saved queries", + count(inventory.saved_queries), + &[("n", &inventory.saved_queries.to_string())], + ); + format!("{connections} · {queries}") +} + +// What the passwords switch can do for this app on Linux, said before anything is read. +pub(super) fn password_option(app: ForeignApp) -> OptionText { + match (app.password_source(), app) { + (PasswordSource::AppFiles, _) => OptionText { + offered: true, + subtitle: gettext("Saved passwords are decrypted while reading"), + }, + // This one can keep them in a KeePass file of its own instead of the keyring. + (PasswordSource::Keychain, ForeignApp::DataGrip) => OptionText { + offered: true, + subtitle: gettext_f( + "Read from the password file of {app} when it keeps one there", + &[("app", app.display_name())], + ), + }, + (PasswordSource::Keychain, _) => OptionText { + offered: false, + subtitle: gettext_f( + "{app} keeps passwords in the system keyring, which TablePro cannot read", + &[("app", app.display_name())], + ), + }, + } +} + +pub(super) fn saved_query_option(app: ForeignApp) -> OptionText { + match app.saved_query_support() { + SavedQuerySupport::Scripts => OptionText { + offered: true, + subtitle: gettext( + "Scripts import with the connection they run on. Scripts with a default name start unchecked.", + ), + }, + SavedQuerySupport::Consoles => OptionText { + offered: true, + subtitle: gettext( + "Query consoles import with their data source. Consoles with a default name start unchecked.", + ), + }, + SavedQuerySupport::GlobalFolder => OptionText { + offered: false, + subtitle: gettext_f( + "{app} keeps saved queries apart from its connections, and TablePro keeps each with a connection", + &[("app", app.display_name())], + ), + }, + SavedQuerySupport::NotInExport => OptionText { + offered: false, + subtitle: gettext_f( + "The {app} export file has no saved queries", + &[("app", app.display_name())], + ), + }, + } +} + +pub(super) fn password_banner(app: ForeignApp, outcome: PasswordOutcome) -> Option { + match outcome { + PasswordOutcome::LeftInKeychain => Some(gettext_f( + "Passwords stay in the keyring {app} uses. Enter them when you connect.", + &[("app", app.display_name())], + )), + PasswordOutcome::Stopped => Some(gettext("Some passwords were not read. Enter them when you connect.")), + PasswordOutcome::NotRequested | PasswordOutcome::Read => None, + } +} + +pub(super) fn collect_failure(app: ForeignApp, error: &ForeignImportError) -> String { + match error { + ForeignImportError::FileNotFound { .. } => gettext_f( + "The files of {app} are not where it keeps them.", + &[("app", app.display_name())], + ), + ForeignImportError::NothingFound => gettext_f( + "{app} has no saved connections to import.", + &[("app", app.display_name())], + ), + ForeignImportError::Unreadable { .. } + | ForeignImportError::Parse { .. } + | ForeignImportError::UnsupportedFormat { .. } => { + tracing::warn!(code = error.code(), "could not read another app's connections"); + gettext_f( + "The connections of {app} could not be read. Its files may be from a version TablePro does not know.", + &[("app", app.display_name())], + ) + } + ForeignImportError::Cancelled => gettext("The import was cancelled."), + } +} + +pub(super) fn resolution_title(resolution: Resolution) -> String { + match resolution { + Resolution::Add => gettext("Add"), + Resolution::AddCopy => gettext("Add as Copy"), + Resolution::Replace(_) => gettext("Replace"), + Resolution::KeepExisting(_) => gettext("Keep Existing, Add Queries"), + } +} + +fn count(n: usize) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) +} + +// Where the connection goes, where it lands, what it matches and what it loses, one line each. +pub(super) fn connection_subtitle(row: &ConnectionRow, drivers: &DriverRegistry) -> String { + let mut lines = Vec::new(); + match &row.connection { + Some(saved) => { + let driver = drivers.get(&saved.driver_id); + let file_based = driver + .as_ref() + .is_some_and(|driver| driver.capabilities().is_file_based()); + let shown = subtitle_for(saved, file_based, None); + let engine = driver + .as_ref() + .map_or(saved.driver_id.as_str(), |driver| driver.display_name()); + lines.push(shown.replacen(&saved.driver_id, engine, 1)); + } + None => lines.push(row.engine.clone()), + } + if !row.group_path.is_empty() { + lines.push(row.group_path.join(GroupTree::PATH_SEPARATOR)); + } + if let Some(existing) = &row.duplicate { + lines.push(gettext_f("Matches “{name}â€�", &[("name", &existing.name)])); + } + lines.extend(row.notes.iter().map(note_text)); + lines.join("\n") +} + +pub(super) fn note_text(note: &RowNote) -> String { + match note { + RowNote::UnsupportedEngine { engine } => { + gettext_f("TablePro cannot open {engine} connections", &[("engine", engine)]) + } + RowNote::SshKeyMissing { path } => gettext_f("SSH private key not found: {path}", &[("path", path)]), + RowNote::JumpHostKeyMissing { path } => gettext_f("Jump host key not found: {path}", &[("path", path)]), + RowNote::CaCertificateMissing { path } => gettext_f("CA certificate not found: {path}", &[("path", path)]), + RowNote::ClientCertificateMissing { path } => { + gettext_f("Client certificate not found: {path}", &[("path", path)]) + } + RowNote::ClientKeyMissing { path } => gettext_f("Client key not found: {path}", &[("path", path)]), + RowNote::TlsModeChanged { from, to } => gettext_f( + "SSL {from} becomes {to}", + &[("from", &ssl_mode_name(*from)), ("to", &tls_mode_name(*to))], + ), + RowNote::TlsSettingsNotRead => gettext("Its TLS settings could not be read, so it would connect without TLS"), + RowNote::TlsNotKept { from } => gettext_f( + "SSL {mode} is not kept: this engine connects without TLS", + &[("mode", &ssl_mode_name(*from))], + ), + RowNote::CaFileNotKept { path, reason } => gettext_f( + "CA certificate {path} is not kept: {reason}", + &[("path", path), ("reason", &file_reason(*reason))], + ), + RowNote::ClientIdentityNotKept { reason } => gettext_f( + "The client certificate is not kept: {reason}", + &[("reason", &file_reason(*reason))], + ), + RowNote::SshNotKept => gettext("The SSH tunnel is not kept: a database file opens on this computer"), + RowNote::SshKeyNotKept { path } => gettext_f("SSH key {path} is not kept", &[("path", path)]), + RowNote::JumpHostNotKept { host } => gettext_f("Jump host {host} is not kept", &[("host", host)]), + RowNote::JumpHostPasswordNotKept { host } => gettext_f( + "Jump host {host} signs in through the SSH agent or the SSH config", + &[("host", host)], + ), + RowNote::JumpHostKeyNotKept { host, path } => gettext_f( + "The key {path} of jump host {host} is not kept", + &[("host", host), ("path", path)], + ), + RowNote::FieldsNotKept { fields } => gettext_f("Not kept: {fields}", &[("fields", &fields.join(", "))]), + } +} + +fn ssl_mode_name(mode: ForeignSslMode) -> String { + match mode { + ForeignSslMode::Disabled => gettext("Disabled"), + ForeignSslMode::Preferred => gettext("Preferred"), + ForeignSslMode::Required => gettext("Required"), + ForeignSslMode::VerifyCa => gettext("Verify CA"), + ForeignSslMode::VerifyIdentity => gettext("Verify Identity"), + } +} + +fn tls_mode_name(mode: TlsMode) -> String { + crate::ui::connect_dialog::tls_mode_label(mode) +} + +fn file_reason(reason: TlsFileReason) -> String { + match reason { + TlsFileReason::EngineLacks => gettext("this engine does not take one"), + TlsFileReason::ModeLacks => gettext("the SSL mode does not use it"), + TlsFileReason::RelativePath => gettext("its path is not a full path"), + TlsFileReason::Incomplete => gettext("a certificate needs its key, and a key its certificate"), + } +} + +pub(super) fn query_subtitle(row: &QueryRow, status: Option) -> String { + let availability = status.map(|status| status.availability); + match availability { + Some(QueryAvailability::NoConnection) => { + gettext("Not imported: TablePro keeps each saved query with a connection") + } + Some(QueryAvailability::ConnectionSkipped) => gettext("Not imported: its connection is not imported"), + Some(QueryAvailability::TooLarge) => gettext("Not imported: larger than a saved query can be"), + Some(QueryAvailability::NameTooLong) => gettext("Not imported: its name is longer than a saved query's can be"), + Some(QueryAvailability::AddedByAnotherRow) => { + gettext("Another query of this name is imported for its connection") + } + Some(QueryAvailability::Available) | None => match &row.connection_name { + Some(name) => gettext_f("On {connection}", &[("connection", name)]), + None => String::new(), + }, + } +} diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index a8109d9d06..2f6899657b 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -29,6 +29,7 @@ mod host_key_failure; mod host_key_review; pub(crate) mod icons; mod import_dialog; +mod import_from_app; pub(crate) mod import_preview; mod key_tab; mod menu_label; diff --git a/linux/crates/app/src/ui/session_window/import_from_app.rs b/linux/crates/app/src/ui/session_window/import_from_app.rs new file mode 100644 index 0000000000..e6e8d5f68f --- /dev/null +++ b/linux/crates/app/src/ui/session_window/import_from_app.rs @@ -0,0 +1,116 @@ +use libadwaita::prelude::*; +use tablepro_foreign_import::review::{ImportPlan, ImportReview, QueryImportOutcome, save_queries}; +use tablepro_storage::StorageError; + +use super::{SessionWindow, SessionWindowInput}; +use crate::i18n::{gettext, ngettext_f}; +use crate::runtime::DeliverInput; +use crate::services::saved_connection_editor::ImportedFromApp; +use crate::services::saved_connection_error::SavedConnectionError; +use crate::ui::import_from_app::{ImportFromAppDialog, ImportFromAppInit}; + +impl SessionWindow { + pub(super) fn on_import_from_app(&self) { + let Some(state) = self.state() else { + return; + }; + let services = &state.services; + let window = self.downgrade(); + let dialog = ImportFromAppDialog::new(ImportFromAppInit { + dirs: tablepro_linux_platform::foreign_app_dirs(), + drivers: services.drivers.clone(), + connections: services.storage.connections().clone(), + tasks: services.tasks.clone(), + on_import: Box::new(move |review, plan| { + if let Some(window) = window.upgrade() { + window.import_from_app(review, plan); + } + }), + }); + dialog.present(Some(self)); + } + + fn import_from_app(&self, review: ImportReview, plan: ImportPlan) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let editor = state.services.saved_connection_editor.clone(); + let saved_queries = state.services.history().store().map(|history| history.saved_queries()); + crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { + let message = match editor.add_imported(review, plan).await { + Ok(imported) => { + let queries = match &saved_queries { + Some(store) if !imported.applied.queries.is_empty() => { + Some(save_queries(&imported.applied, store).await) + } + _ => None, + }; + imported_message(&imported, queries, saved_queries.is_some()) + } + Err(SavedConnectionError::Storage(StorageError::ImportOutdated)) => { + gettext("The saved connections changed while you were deciding. Import again.") + } + Err(error) => { + tracing::warn!(%error, "could not import from another app"); + crate::ui::error_text::saved_connection_error_message(&error) + } + }; + input.deliver(SessionWindowInput::ShowToast(message)); + }); + } +} + +pub(super) fn imported_message( + imported: &ImportedFromApp, + queries: Option, + queries_available: bool, +) -> String { + let connections = imported.applied.connections.len(); + let mut parts = Vec::new(); + if connections > 0 { + parts.push(ngettext_f( + "Imported {n} connection.", + "Imported {n} connections.", + count(connections), + &[("n", &connections.to_string())], + )); + } + if let Some(queries) = queries { + if queries.added > 0 { + parts.push(ngettext_f( + "Added {n} saved query.", + "Added {n} saved queries.", + count(queries.added), + &[("n", &queries.added.to_string())], + )); + } + if queries.failed > 0 { + parts.push(ngettext_f( + "{n} saved query could not be added.", + "{n} saved queries could not be added.", + count(queries.failed), + &[("n", &queries.failed.to_string())], + )); + } + } else if !queries_available && !imported.applied.queries.is_empty() { + parts.push(gettext( + "Saved queries were not added: the query history is not available.", + )); + } + if imported.secrets_not_kept > 0 { + parts.push(ngettext_f( + "{n} password could not be kept in the keyring.", + "{n} passwords could not be kept in the keyring.", + count(imported.secrets_not_kept), + &[("n", &imported.secrets_not_kept.to_string())], + )); + } + if parts.is_empty() { + return gettext("Nothing was imported."); + } + parts.join(" ") +} + +fn count(n: usize) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) +} diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index c6b3816bc8..292ba373d6 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -16,6 +16,7 @@ mod connecting_page; mod connections; mod file_location_dialog; mod imp; +mod import_from_app; mod launch_notice; mod locate_file; mod notification_request; @@ -528,6 +529,7 @@ impl SessionWindow { SessionWindowInput::ImportConnections => self.on_import_connections(), SessionWindowInput::ConnectionsFileRead { path, bytes } => self.on_connections_file_read(path, bytes), SessionWindowInput::ImportPreviewed(preview) => self.on_import_previewed(*preview), + SessionWindowInput::ImportFromApp => self.on_import_from_app(), SessionWindowInput::ShowToast(message) => self.show_toast(&message), } } diff --git a/linux/crates/app/src/ui/session_window/session_window_input.rs b/linux/crates/app/src/ui/session_window/session_window_input.rs index 7f15865c4e..e08e1a2a1d 100644 --- a/linux/crates/app/src/ui/session_window/session_window_input.rs +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -170,6 +170,7 @@ pub(crate) enum SessionWindowInput { }, /// What importing that file would do, for the user to agree to. ImportPreviewed(Box), + ImportFromApp, ShowToast(String), } diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs index e9c02cf709..9e698ff9f7 100644 --- a/linux/crates/app/src/ui/session_window/window_actions.rs +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -28,7 +28,7 @@ const SAFE_MODE: &str = "safe-mode"; /// The window's actions and the input each one sends. A closure holds /// only the window's input sender, so an action that outlives the window /// drops its input rather than reaching a window that is gone. -const ACTIONS: [(&str, ActionInput); 11] = [ +const ACTIONS: [(&str, ActionInput); 12] = [ ("new-connection", || SessionWindowInput::OpenConnectDialog), ("close-current", || SessionWindowInput::CloseCurrent), ("disconnect", || SessionWindowInput::Disconnect), @@ -39,6 +39,7 @@ const ACTIONS: [(&str, ActionInput); 11] = [ ("reset-workspace-layout", || SessionWindowInput::ResetWorkspaceLayout), ("export-connections", || SessionWindowInput::ExportConnections), ("import-connections", || SessionWindowInput::ImportConnections), + ("import-from-app", || SessionWindowInput::ImportFromApp), ("manage-tags", || SessionWindowInput::ManageTags), ]; diff --git a/linux/crates/platform/Cargo.toml b/linux/crates/platform/Cargo.toml index c6f542ae7a..209eae4398 100644 --- a/linux/crates/platform/Cargo.toml +++ b/linux/crates/platform/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] tablepro-core.workspace = true +tablepro-foreign-import.workspace = true tablepro-storage.workspace = true async-trait.workspace = true gio.workspace = true diff --git a/linux/crates/platform/src/lib.rs b/linux/crates/platform/src/lib.rs index 9bd546733f..9a5b6b0801 100644 --- a/linux/crates/platform/src/lib.rs +++ b/linux/crates/platform/src/lib.rs @@ -2,6 +2,6 @@ mod paths; mod secrets; pub mod settings; -pub use paths::storage_paths; +pub use paths::{foreign_app_dirs, storage_paths}; pub use secrets::SecretStore; pub use settings::{AppSettings, EditorFont, SettingsError, WindowGeometry}; diff --git a/linux/crates/platform/src/paths.rs b/linux/crates/platform/src/paths.rs index 9678d4a17a..1cf85fc0ee 100644 --- a/linux/crates/platform/src/paths.rs +++ b/linux/crates/platform/src/paths.rs @@ -1,3 +1,6 @@ +use std::path::{Path, PathBuf}; + +use tablepro_foreign_import::{ForeignAppDirs, ForeignPlatform}; use tablepro_storage::StoragePaths; /// GLib resolves the XDG base directories, including the fallbacks @@ -12,3 +15,26 @@ pub fn storage_paths(dir_name: &str, app_id: &str) -> StoragePaths { runtime: glib::user_runtime_dir().join("app").join(app_id), } } + +// Inside a Flatpak, GLib's XDG directories are the sandbox's own, and other apps keep their files +// in the host's, which Flatpak passes as HOST_XDG_*. +pub fn foreign_app_dirs() -> ForeignAppDirs { + let home = glib::home_dir(); + let sandboxed = Path::new("/.flatpak-info").exists(); + let host_dir = |variable: &str, sandbox_default: PathBuf, desktop_default: PathBuf| { + std::env::var_os(variable) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or(if sandboxed { sandbox_default } else { desktop_default }) + }; + ForeignAppDirs { + platform: ForeignPlatform::Linux, + config: host_dir("HOST_XDG_CONFIG_HOME", home.join(".config"), glib::user_config_dir()), + data: host_dir( + "HOST_XDG_DATA_HOME", + home.join(".local").join("share"), + glib::user_data_dir(), + ), + home, + } +} diff --git a/linux/data/licenses/THIRD-PARTY-NOTICE.md b/linux/data/licenses/THIRD-PARTY-NOTICE.md index e0db46c3a3..cc5e47f9a2 100644 --- a/linux/data/licenses/THIRD-PARTY-NOTICE.md +++ b/linux/data/licenses/THIRD-PARTY-NOTICE.md @@ -3918,6 +3918,42 @@ DEALINGS IN THE SOFTWARE. Used by: +- blowfish 0.9.1 + +~~~~text +Copyright (c) 2006-2009 Graydon Hoare +Copyright (c) 2009-2013 Mozilla Foundation + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - md-5 0.10.6 - sha2 0.10.9 @@ -4583,6 +4619,34 @@ DEALINGS IN THE SOFTWARE. Used by: +- plist 1.10.3 + +~~~~text +Copyright (c) 2015 Edward Barnard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + - tempfile 3.27.0 ~~~~text @@ -6362,6 +6426,41 @@ SOFTWARE. Used by: +- salsa20 0.10.2 + +~~~~text +Copyright (c) 2019 Eric McCorkle + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - futures-intrusive 0.5.0 ~~~~text @@ -6542,6 +6641,41 @@ DEALINGS IN THE SOFTWARE. Used by: +- chacha20 0.9.1 + +~~~~text +Copyright (c) 2019-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - http-body-util 0.1.5 - http-body 1.1.0 @@ -9223,6 +9357,75 @@ SOFTWARE. Used by: +- miniz_oxide 0.9.1 + +~~~~text +MIT License + +Copyright 2013-2014 RAD Game Tools and Valve Software +Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC +Copyright (c) 2017 Frommi +Copyright (c) 2017-2024 oyvindln + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- miniz_oxide 0.9.1 + +~~~~text +MIT License + +Copyright 2013-2014 RAD Game Tools and Valve Software +Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC +Copyright (c) 2017 Frommi +Copyright (c) 2017-2024 oyvindln + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - rust_xlsxwriter 0.102.0 ~~~~text @@ -9379,6 +9582,7 @@ DEALINGS IN THE SOFTWARE. Used by: +- adler2 2.0.1 - async-channel 2.5.0 - async-lock 3.4.2 - async-task 4.7.1 @@ -10139,6 +10343,39 @@ SOFTWARE. Used by: +- quick-xml 0.42.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2016 Johann Tuffe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - gettext-rs 0.8.0 - gettext-sys 0.26.0 @@ -10240,6 +10477,37 @@ THE SOFTWARE. Used by: +- roxmltree 0.20.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2018 Yevhenii Reizner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + - bstr 1.13.1 ~~~~text diff --git a/linux/data/licenses/third-party.json b/linux/data/licenses/third-party.json index 20a585533b..b8b6373d86 100644 --- a/linux/data/licenses/third-party.json +++ b/linux/data/licenses/third-party.json @@ -243,6 +243,14 @@ { "crate": { "name": "sqlx", "version": "0.9.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2006-2009 Graydon Hoare\nCopyright (c) 2009-2013 Mozilla Foundation\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "blowfish", "version": "0.9.1" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -419,6 +427,14 @@ { "crate": { "name": "serde_with_macros", "version": "3.23.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015 Edward Barnard\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "plist", "version": "1.10.3" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -871,6 +887,14 @@ { "crate": { "name": "matchers", "version": "0.2.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Eric McCorkle\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "salsa20", "version": "0.10.2" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -916,6 +940,14 @@ { "crate": { "name": "tower-service", "version": "0.3.3" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019-2023 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "chacha20", "version": "0.9.1" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1641,6 +1673,22 @@ { "crate": { "name": "libgssapi", "version": "0.11.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright 2013-2014 RAD Game Tools and Valve Software\nCopyright 2010-2014 Rich Geldreich and Tenacious Software LLC\nCopyright (c) 2017 Frommi\nCopyright (c) 2017-2024 oyvindln\n\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "miniz_oxide", "version": "0.9.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright 2013-2014 RAD Game Tools and Valve Software\nCopyright 2010-2014 Rich Geldreich and Tenacious Software LLC\nCopyright (c) 2017 Frommi\nCopyright (c) 2017-2024 oyvindln\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "miniz_oxide", "version": "0.9.1" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1690,6 +1738,7 @@ "name": "MIT License", "text": "Permission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", "used_by": [ + { "crate": { "name": "adler2", "version": "2.0.1" } }, { "crate": { "name": "async-channel", "version": "2.5.0" } }, { "crate": { "name": "async-lock", "version": "3.4.2" } }, { "crate": { "name": "async-task", "version": "4.7.1" } }, @@ -1959,6 +2008,14 @@ { "crate": { "name": "derive_more", "version": "2.1.1" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2016 Johann Tuffe\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "quick-xml", "version": "0.42.0" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1986,6 +2043,14 @@ { "crate": { "name": "locale_config", "version": "0.3.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2018 Yevhenii Reizner\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "roxmltree", "version": "0.20.0" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -2401,6 +2466,7 @@ } ], "crates": [ + { "name": "adler2", "version": "2.0.1", "license": "0BSD OR MIT OR Apache-2.0" }, { "name": "aes", "version": "0.8.4", "license": "MIT OR Apache-2.0" }, { "name": "ahash", "version": "0.8.12", "license": "MIT OR Apache-2.0" }, { "name": "aho-corasick", "version": "1.1.5", "license": "Unlicense OR MIT" }, @@ -2441,6 +2507,7 @@ { "name": "block-buffer", "version": "0.12.1", "license": "MIT OR Apache-2.0" }, { "name": "block-padding", "version": "0.3.3", "license": "MIT OR Apache-2.0" }, { "name": "blocking", "version": "1.7.0", "license": "Apache-2.0 OR MIT" }, + { "name": "blowfish", "version": "0.9.1", "license": "MIT OR Apache-2.0" }, { "name": "bnum", "version": "0.13.0", "license": "MIT OR Apache-2.0" }, { "name": "bson", "version": "3.1.0", "license": "MIT" }, { "name": "bstr", "version": "1.13.1", "license": "MIT OR Apache-2.0" }, @@ -2453,6 +2520,7 @@ { "name": "cast", "version": "0.3.0", "license": "MIT OR Apache-2.0" }, { "name": "cbc", "version": "0.1.2", "license": "MIT OR Apache-2.0" }, { "name": "cfg-if", "version": "1.0.4", "license": "MIT OR Apache-2.0" }, + { "name": "chacha20", "version": "0.9.1", "license": "Apache-2.0 OR MIT" }, { "name": "chacha20", "version": "0.10.2", "license": "MIT OR Apache-2.0" }, { "name": "chrono", "version": "0.4.45", "license": "MIT OR Apache-2.0" }, { "name": "cipher", "version": "0.4.4", "license": "MIT OR Apache-2.0" }, @@ -2669,6 +2737,7 @@ { "name": "memfd", "version": "0.6.6", "license": "MIT OR Apache-2.0" }, { "name": "memmap2", "version": "0.9.12", "license": "MIT OR Apache-2.0" }, { "name": "memoffset", "version": "0.9.1", "license": "MIT" }, + { "name": "miniz_oxide", "version": "0.9.1", "license": "MIT OR Zlib OR Apache-2.0" }, { "name": "mio", "version": "1.2.3", "license": "MIT" }, { "name": "moka", "version": "0.12.16", "license": "(MIT OR Apache-2.0) AND Apache-2.0" }, { "name": "mongodb", "version": "3.9.1", "license": "Apache-2.0" }, @@ -2709,6 +2778,7 @@ { "name": "piper", "version": "0.2.5", "license": "MIT OR Apache-2.0" }, { "name": "pkcs1", "version": "0.8.0-rc.4", "license": "Apache-2.0 OR MIT" }, { "name": "pkcs8", "version": "0.11.0", "license": "Apache-2.0 OR MIT" }, + { "name": "plist", "version": "1.10.3", "license": "MIT" }, { "name": "portable-atomic", "version": "1.15.0", "license": "Apache-2.0 OR MIT" }, { "name": "postgres-protocol", "version": "0.6.12", "license": "MIT OR Apache-2.0" }, { "name": "postgres-types", "version": "0.2.14", "license": "MIT OR Apache-2.0" }, @@ -2720,6 +2790,7 @@ { "name": "proc-macro-crate", "version": "3.5.0", "license": "MIT OR Apache-2.0" }, { "name": "proc-macro2", "version": "1.0.107", "license": "MIT OR Apache-2.0" }, { "name": "pulldown-cmark", "version": "0.13.6", "license": "MIT" }, + { "name": "quick-xml", "version": "0.42.0", "license": "MIT" }, { "name": "quote", "version": "1.0.47", "license": "MIT OR Apache-2.0" }, { "name": "radium", "version": "0.7.0", "license": "MIT" }, { "name": "rand", "version": "0.9.5", "license": "MIT OR Apache-2.0" }, @@ -2742,6 +2813,7 @@ { "name": "rmcp", "version": "3.6.0", "license": "Apache-2.0 AND MIT" }, { "name": "rmp", "version": "0.8.15", "license": "MIT" }, { "name": "rmp-serde", "version": "1.3.1", "license": "MIT" }, + { "name": "roxmltree", "version": "0.20.0", "license": "MIT OR Apache-2.0" }, { "name": "rsa", "version": "0.10.0-rc.18", "license": "MIT OR Apache-2.0" }, { "name": "rust_xlsxwriter", "version": "0.102.0", "license": "MIT OR Apache-2.0" }, { "name": "rustc_version", "version": "0.4.1", "license": "MIT OR Apache-2.0" }, @@ -2754,6 +2826,7 @@ { "name": "rustls-webpki", "version": "0.103.16", "license": "ISC" }, { "name": "rustversion", "version": "1.0.23", "license": "MIT OR Apache-2.0" }, { "name": "ryu", "version": "1.0.23", "license": "Apache-2.0 OR BSL-1.0" }, + { "name": "salsa20", "version": "0.10.2", "license": "MIT OR Apache-2.0" }, { "name": "schemars", "version": "1.2.2", "license": "MIT" }, { "name": "schemars_derive", "version": "1.2.2", "license": "MIT" }, { "name": "scopeguard", "version": "1.2.0", "license": "MIT OR Apache-2.0" }, diff --git a/linux/data/resources/gtk/menus.ui b/linux/data/resources/gtk/menus.ui index bb552aa9fb..6e776237c9 100644 --- a/linux/data/resources/gtk/menus.ui +++ b/linux/data/resources/gtk/menus.ui @@ -39,6 +39,10 @@ _Import Connections… win.import-connections + + Import f_rom Another App… + win.import-from-app + _Export Connections… win.export-connections diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index b8c8f52bed..9eb7d6a843 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -23,6 +23,7 @@ ui/find-bar.ui ui/history-dialog.ui ui/history-row.ui + ui/import-from-app-dialog.ui ui/pager-bar.ui ui/pending-changes-bar.ui ui/picker-page.ui diff --git a/linux/data/resources/ui/import-from-app-dialog.ui b/linux/data/resources/ui/import-from-app-dialog.ui new file mode 100644 index 0000000000..a345b2fee4 --- /dev/null +++ b/linux/data/resources/ui/import-from-app-dialog.ui @@ -0,0 +1,184 @@ + + + + + + diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 36dfeb8ab5..6f95029362 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -72,6 +72,7 @@ crates/app/src/services/duplicate_name.rs crates/app/src/services/file_location.rs crates/app/src/services/file_location_kind.rs crates/app/src/services/file_resolution.rs +crates/app/src/services/foreign_import.rs crates/app/src/services/grid/cell_editability.rs crates/app/src/services/grid/cell_editor_kind.rs crates/app/src/services/grid/cell_filter.rs @@ -437,6 +438,10 @@ crates/app/src/ui/host_key_failure.rs crates/app/src/ui/host_key_review.rs crates/app/src/ui/icons.rs crates/app/src/ui/import_dialog.rs +crates/app/src/ui/import_from_app/imp.rs +crates/app/src/ui/import_from_app/mod.rs +crates/app/src/ui/import_from_app/tests.rs +crates/app/src/ui/import_from_app/wording.rs crates/app/src/ui/import_preview.rs crates/app/src/ui/key_tab/key_summary.rs crates/app/src/ui/key_tab/mod.rs @@ -475,6 +480,7 @@ crates/app/src/ui/session_window/connecting_page.rs crates/app/src/ui/session_window/connections.rs crates/app/src/ui/session_window/file_location_dialog.rs crates/app/src/ui/session_window/imp.rs +crates/app/src/ui/session_window/import_from_app.rs crates/app/src/ui/session_window/launch_notice.rs crates/app/src/ui/session_window/locate_file.rs crates/app/src/ui/session_window/mod.rs @@ -708,6 +714,7 @@ data/resources/ui/filter-strip.ui data/resources/ui/find-bar.ui data/resources/ui/history-dialog.ui data/resources/ui/history-row.ui +data/resources/ui/import-from-app-dialog.ui data/resources/ui/pager-bar.ui data/resources/ui/pending-changes-bar.ui data/resources/ui/picker-page.ui From f1841eb55c7f42e755574dd08d84b788a63f473b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 15:47:15 +0700 Subject: [PATCH 02/12] docs(linux): changelog fragment for importing from another app --- linux/changelog.d/4015.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/4015.added.md diff --git a/linux/changelog.d/4015.added.md b/linux/changelog.d/4015.added.md new file mode 100644 index 0000000000..79c3fe55e8 --- /dev/null +++ b/linux/changelog.d/4015.added.md @@ -0,0 +1 @@ +Import connections and saved queries from another database app, after a review of what each one brings. From 2dcee796c811f7e4bb3b1544a96328abe41081a4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 15:56:39 +0700 Subject: [PATCH 03/12] fix(core): mark TLS an importer cannot read instead of importing it off --- .../foreign-import/src/apps/datagrip/mod.rs | 24 ++- .../foreign-import/src/apps/dbeaver/mod.rs | 65 ++++++-- core/crates/foreign-import/src/apps/jdbc.rs | 145 ++++++++---------- .../src/apps/tableplus/connection.rs | 6 +- core/crates/foreign-import/tests/tableplus.rs | 7 +- 5 files changed, 150 insertions(+), 97 deletions(-) diff --git a/core/crates/foreign-import/src/apps/datagrip/mod.rs b/core/crates/foreign-import/src/apps/datagrip/mod.rs index 52c2b6ccbc..1293f6a02e 100644 --- a/core/crates/foreign-import/src/apps/datagrip/mod.rs +++ b/core/crates/foreign-import/src/apps/datagrip/mod.rs @@ -304,13 +304,12 @@ fn make_connection(source: &DataSource, ssh_configs: &HashMap Some(endpoint) => (endpoint.host, endpoint.database, endpoint.username), None => ("localhost".to_owned(), String::new(), None), }; - let additional_fields = jdbc::url_fields(&source.jdbc_url); + let mut additional_fields = jdbc::url_fields(&source.jdbc_url); // The macOS port read TLS from the XML alone, so a URL that asks for it imported in the clear. - let ssl = source - .ssl - .as_ref() - .map(make_ssl) - .or_else(|| jdbc::url_ssl(&source.jdbc_url)); + let ssl = match source.ssl.as_ref() { + Some(ssl) => Some(make_ssl(ssl)), + None => jdbc::url_ssl_or_mark(&source.jdbc_url, &mut additional_fields), + }; ForeignConnection { name: source.name.clone(), host, @@ -475,6 +474,19 @@ mod tests { &HashMap::new(), ); assert_eq!(plain.ssl, None); + assert!(plain.additional_fields.is_empty()); + let unread = make_connection( + &source("postgresql", "jdbc:postgresql://db.example.com/app?ssl=true"), + &HashMap::new(), + ); + assert_eq!(unread.ssl, None); + assert_eq!( + unread + .additional_fields + .get(crate::TLS_SETTINGS_UNREAD) + .map(String::as_str), + Some("true") + ); } #[test] diff --git a/core/crates/foreign-import/src/apps/dbeaver/mod.rs b/core/crates/foreign-import/src/apps/dbeaver/mod.rs index a77f279a97..850e9d387d 100644 --- a/core/crates/foreign-import/src/apps/dbeaver/mod.rs +++ b/core/crates/foreign-import/src/apps/dbeaver/mod.rs @@ -15,7 +15,8 @@ use crate::apps::{jdbc, resolve_key_path}; use crate::collect::{PasswordOutcome, assemble}; use crate::{ CollectedImport, ForeignApp, ForeignAppDirs, ForeignColor, ForeignConnection, ForeignConnectionRecord, - ForeignImportError, ForeignSsh, ForeignSshAuth, ForeignSsl, ForeignSslMode, ImportRequest, Inventory, engine, + ForeignCredentials, ForeignImportError, ForeignSsh, ForeignSshAuth, ForeignSsl, ForeignSslMode, ImportRequest, + Inventory, engine, }; pub(crate) struct DBeaver { @@ -66,14 +67,17 @@ impl AppReader for DBeaver { for source in &sources { for (id, connection) in &source.connections { check_cancel(cancel)?; + let settings = settings(id, connection, source.credentials.username(id)); + let credentials = request + .include_passwords + .then(|| source.credentials.secrets(id)) + .flatten() + .map(|credentials| tunnel_secret_by_auth(credentials, &settings)); records.push(ForeignConnectionRecord { source_id: Some(id.clone()), - settings: settings(id, connection, source.credentials.username(id)), + settings, group_path: group_path(connection.get("folder").and_then(Value::as_str), &folders), - credentials: request - .include_passwords - .then(|| source.credentials.secrets(id)) - .flatten(), + credentials, }); } } @@ -195,6 +199,19 @@ fn group_path(folder: Option<&str>, descriptions: &BTreeMap) -> static NOTHING: Value = Value::Null; +// The tunnel keeps one secret: the password for password sign-in, the key's passphrase for a key. +// The macOS port always took it as a password, which a key tunnel then dropped. +fn tunnel_secret_by_auth(mut credentials: ForeignCredentials, settings: &ForeignConnection) -> ForeignCredentials { + let key_tunnel = settings + .ssh + .as_ref() + .is_some_and(|ssh| ssh.auth == ForeignSshAuth::PrivateKey); + if key_tunnel && credentials.key_passphrase.is_none() { + credentials.key_passphrase = credentials.ssh_password.take(); + } + credentials +} + fn settings(id: &str, connection: &Value, credential_username: Option<&str>) -> ForeignConnection { let name = connection.get("name").and_then(Value::as_str).unwrap_or(id).to_owned(); let engine = map_provider(connection.get("provider").and_then(Value::as_str).unwrap_or("")); @@ -223,6 +240,13 @@ fn settings(id: &str, connection: &Value, credential_username: Option<&str>) -> .flatten() .find(|username| !username.is_empty()) .unwrap_or(""); + // Read whether or not the host came from the URL: a seed list and an auth database live only there. + let mut additional_fields = text("url").map(jdbc::url_fields).unwrap_or_default(); + // A URL-only connection keeps the TLS its URL asks for, which the macOS port dropped. + let ssl = match ssl(config) { + Some(ssl) => Some(ssl), + None => text("url").and_then(|url| jdbc::url_ssl_or_mark(url, &mut additional_fields)), + }; ForeignConnection { name, host, @@ -232,11 +256,9 @@ fn settings(id: &str, connection: &Value, credential_username: Option<&str>) -> database, username: username.to_owned(), ssh: ssh(config), - // A URL-only connection keeps the TLS its URL asks for, which the macOS port dropped. - ssl: ssl(config).or_else(|| text("url").and_then(jdbc::url_ssl)), + ssl, color: color(config), - // Read whether or not the host came from the URL: a seed list and an auth database live only there. - additional_fields: text("url").map(jdbc::url_fields).unwrap_or_default(), + additional_fields, engine, } } @@ -402,6 +424,7 @@ mod tests { assert!(!format!("{settings:?}").contains("hunter2")); assert_eq!(settings.username, "admin"); assert_eq!(settings.ssl.map(|ssl| ssl.mode), Some(ForeignSslMode::Required)); + assert_eq!(settings.additional_fields.get(crate::TLS_SETTINGS_UNREAD), None); } #[test] @@ -434,6 +457,28 @@ mod tests { ); } + #[test] + fn a_key_tunnels_secret_is_the_keys_passphrase() { + use secrecy::{ExposeSecret, SecretString}; + + let connection = parsed( + r#"{"provider": "postgresql", "name": "tunnelled", + "configuration": {"host": "db", "handlers": {"ssh_tunnel": {"enabled": true, + "properties": {"host": "bastion", "authType": "PUBLIC_KEY", "keyPath": "~/.ssh/id"}}}}}"#, + ); + let settings = settings("tunnelled-id", &connection, None); + let credentials = ForeignCredentials { + ssh_password: Some(SecretString::from("phrase")), + ..ForeignCredentials::default() + }; + let routed = tunnel_secret_by_auth(credentials, &settings); + assert!(routed.ssh_password.is_none()); + assert_eq!( + routed.key_passphrase.as_ref().map(ExposeSecret::expose_secret), + Some("phrase") + ); + } + #[test] fn a_url_that_asks_to_verify_the_server_keeps_it() { let connection = parsed( diff --git a/core/crates/foreign-import/src/apps/jdbc.rs b/core/crates/foreign-import/src/apps/jdbc.rs index 1d330ae164..66b1d6d2f9 100644 --- a/core/crates/foreign-import/src/apps/jdbc.rs +++ b/core/crates/foreign-import/src/apps/jdbc.rs @@ -78,35 +78,35 @@ pub(crate) fn is_mongo_seed_list(url: &str) -> bool { strip_prefix_ignoring_case(url.trim(), "mongodb+srv://").is_some() } -// The TLS a URL asks for, in the macOS SSL mode names, read the way the URL's own driver reads it: -// an `ssl=true` verifies the server for PostgreSQL, SQL Server and MongoDB but not for MySQL, whose -// driver checks the certificate only when told to. A seed-list MongoDB URL turns TLS on by itself. -pub(crate) fn url_ssl_mode(url: &str) -> Option<&'static str> { - let protocol = url_protocol(url); +// What a URL says about TLS. Only `sslmode` and a seed-list scheme say it plainly; the drivers read +// every other switch their own way and in combination (`ssl`, `useSSL` with `requireSSL`, +// `encrypt` with `trustServerCertificate`, certificate files), so a URL that uses one is reported +// as unread rather than guessed at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UrlTls { + None, + Mode(&'static str), + Unread, +} + +pub(crate) fn url_tls(url: &str) -> UrlTls { let parameters = url_parameters(url); - let flag = |names: &[&str]| { - parameters - .iter() - .rev() - .find(|(key, _)| names.contains(&key.as_str())) - .map(|(_, value)| matches!(value.as_str(), "true" | "1" | "yes" | "required" | "require" | "strict")) - }; - let unverified = flag(&[ - "trustservercertificate", - "tlsallowinvalidcertificates", - "tlsinsecure", - "sslallowinvalidcertificates", - ]) == Some(true) - || (protocol == UrlProtocol::Mysql && flag(&["verifyservercertificate"]) != Some(true)); - let enabled = match parameters.iter().rev().find(|(key, _)| key == "sslmode") { - Some((_, mode)) => return named_mode(mode), - None => flag(&["ssl", "tls", "usessl", "requiressl", "encrypt"]), - }; - match (enabled, protocol) { - (Some(false), _) => Some("disabled"), - (None, UrlProtocol::MongoSeedList) | (Some(true), _) if !unverified => Some("verify-full"), - (None, UrlProtocol::MongoSeedList) | (Some(true), _) => Some("required"), - (None, _) => None, + let other_switch = parameters.iter().any(|(key, _)| { + key != "sslmode" + && (key.starts_with("ssl") + || key.starts_with("tls") + || matches!( + key.as_str(), + "usessl" | "requiressl" | "encrypt" | "trustservercertificate" | "verifyservercertificate" + )) + }); + if other_switch { + return UrlTls::Unread; + } + match parameters.iter().rev().find(|(key, _)| key == "sslmode") { + Some((_, mode)) => named_mode(mode).map_or(UrlTls::Unread, UrlTls::Mode), + None if is_mongo_seed_list(url) => UrlTls::Mode("verify-full"), + None => UrlTls::None, } } @@ -122,23 +122,6 @@ fn named_mode(mode: &str) -> Option<&'static str> { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UrlProtocol { - Mysql, - MongoSeedList, - Other, -} - -fn url_protocol(url: &str) -> UrlProtocol { - if is_mongo_seed_list(url) { - return UrlProtocol::MongoSeedList; - } - match subprotocol(url).to_lowercase().as_str() { - "mysql" | "mariadb" => UrlProtocol::Mysql, - _ => UrlProtocol::Other, - } -} - // Lowercased keys and values after `?` (and SQL Server's `;` properties), in order. fn url_parameters(url: &str) -> Vec<(String, String)> { let Some((_, query)) = url.split_once('?').or_else(|| url.split_once(';')) else { @@ -151,9 +134,24 @@ fn url_parameters(url: &str) -> Vec<(String, String)> { .collect() } -// The TLS a URL asks for, as a record's SSL settings; none for a URL that asks for none or turns it off. +// The SSL settings a source keeps in its URL, used when its own settings name none. TLS the URL +// sets in a way this cannot read marks the record, so the review asks instead of importing it off. +pub(crate) fn url_ssl_or_mark( + url: &str, + fields: &mut std::collections::BTreeMap, +) -> Option { + if url_tls(url) == UrlTls::Unread { + fields.insert(crate::TLS_SETTINGS_UNREAD.to_owned(), "true".to_owned()); + } + url_ssl(url) +} + +// The TLS a URL asks for as a record's SSL settings: none when it asks for none or turns it off. pub(crate) fn url_ssl(url: &str) -> Option { - let mode = match url_ssl_mode(url)? { + let UrlTls::Mode(mode) = url_tls(url) else { + return None; + }; + let mode = match mode { "disabled" => return None, "preferred" => ForeignSslMode::Preferred, "required" => ForeignSslMode::Required, @@ -513,44 +511,37 @@ mod tests { } #[test] - fn the_tls_a_url_asks_for_is_read_as_its_own_driver_reads_it() { - assert_eq!( - url_ssl_mode("jdbc:postgresql://db/app?sslmode=verify-full"), - Some("verify-full") - ); - assert_eq!( - url_ssl_mode("jdbc:postgresql://db/app?ssl=true&sslmode=verify-ca"), - Some("verify-ca") - ); - assert_eq!(url_ssl_mode("jdbc:postgresql://db/app?ssl=true"), Some("verify-full")); + fn only_sslmode_and_a_seed_list_are_read_and_every_other_tls_switch_is_reported_unread() { assert_eq!( - url_ssl_mode("jdbc:postgresql://db/app?sslmode=require"), - Some("required") + url_tls("jdbc:postgresql://db/app?sslmode=verify-full"), + UrlTls::Mode("verify-full") ); - assert_eq!(url_ssl_mode("jdbc:mysql://db/app?sslMode=REQUIRED"), Some("required")); - assert_eq!(url_ssl_mode("jdbc:mariadb://db/app?sslMode=trust"), Some("required")); - assert_eq!(url_ssl_mode("jdbc:mysql://db/app?useSSL=true"), Some("required")); assert_eq!( - url_ssl_mode("jdbc:mysql://db/app?useSSL=true&verifyServerCertificate=true"), - Some("verify-full") + url_tls("jdbc:postgresql://db/app?sslmode=require"), + UrlTls::Mode("required") ); - assert_eq!(url_ssl_mode("jdbc:mysql://db/app?useSSL=false"), Some("disabled")); + assert_eq!(url_tls("jdbc:mariadb://db/app?sslMode=trust"), UrlTls::Mode("required")); assert_eq!( - url_ssl_mode("jdbc:sqlserver://db:1433;databaseName=app;encrypt=true"), - Some("verify-full") + url_tls("jdbc:mysql://db/app?sslMode=VERIFY_IDENTITY"), + UrlTls::Mode("verify-full") ); assert_eq!( - url_ssl_mode("jdbc:sqlserver://db:1433;encrypt=true;trustServerCertificate=true"), - Some("required") + url_tls("mongodb+srv://cluster.example/app"), + UrlTls::Mode("verify-full") ); - assert_eq!(url_ssl_mode("mongodb://db/app?tls=true"), Some("verify-full")); - assert_eq!(url_ssl_mode("mongodb+srv://cluster.example/app"), Some("verify-full")); - assert_eq!( - url_ssl_mode("mongodb+srv://cluster.example/app?tls=false"), - Some("disabled") - ); - assert_eq!(url_ssl_mode("jdbc:postgresql://db/app"), None); - assert_eq!(url_ssl_mode("mongodb://db/app"), None); + assert_eq!(url_tls("jdbc:postgresql://db/app"), UrlTls::None); + assert_eq!(url_tls("mongodb://db/app"), UrlTls::None); + for unread in [ + "jdbc:postgresql://db/app?ssl=true", + "jdbc:postgresql://db/app?sslmode=verify-full&sslrootcert=/ca.pem", + "jdbc:mysql://db/app?useSSL=true&requireSSL=false", + "jdbc:sqlserver://db:1433;databaseName=app;encrypt=true", + "mongodb+srv://cluster.example/app?tls=false", + "jdbc:postgresql://db/app?sslmode=sometimes", + ] { + assert_eq!(url_tls(unread), UrlTls::Unread, "{unread}"); + } + assert_eq!(url_ssl("jdbc:postgresql://db/app?sslmode=disable"), None); } #[test] diff --git a/core/crates/foreign-import/src/apps/tableplus/connection.rs b/core/crates/foreign-import/src/apps/tableplus/connection.rs index 722679e210..9fb7fd2a39 100644 --- a/core/crates/foreign-import/src/apps/tableplus/connection.rs +++ b/core/crates/foreign-import/src/apps/tableplus/connection.rs @@ -212,7 +212,9 @@ impl Mapping<'_> { { additional_fields.insert("promptForPassword".to_owned(), "true".to_owned()); } - if entry.tls_unread { + let ssl = ssl(entry, driver); + // A saved mode outside the form's list is TLS this cannot read, not TLS off. + if entry.tls_unread || (entry.tls_mode.is_some() && ssl.is_none()) { additional_fields.insert(crate::TLS_SETTINGS_UNREAD.to_owned(), "true".to_owned()); } @@ -223,7 +225,7 @@ impl Mapping<'_> { database, username: entry.user.clone().unwrap_or_default(), ssh: self.ssh(entry), - ssl: ssl(entry, driver), + ssl, color: entry.environment.as_deref().and_then(environment_color), engine, additional_fields, diff --git a/core/crates/foreign-import/tests/tableplus.rs b/core/crates/foreign-import/tests/tableplus.rs index d110fd0619..05a4820579 100644 --- a/core/crates/foreign-import/tests/tableplus.rs +++ b/core/crates/foreign-import/tests/tableplus.rs @@ -457,11 +457,14 @@ fn collect_carries_no_tls_paths_for_a_driver_whose_form_has_no_file_pickers() { } #[test] -fn collect_drops_a_tls_mode_past_the_end_of_the_drivers_own_popup() { +fn collect_marks_a_tls_mode_past_the_end_of_the_drivers_own_popup_as_unread() { let collected = macos(CONNECTIONS_ONLY); for name in ["Unknown TLS", "Past MySQL", "Past Oracle"] { - assert!(connection(&collected, name).settings.ssl.is_none(), "{name}"); + let found = connection(&collected, name); + assert!(found.settings.ssl.is_none(), "{name}"); + assert_eq!(field(found, "tlsSettingsUnread"), Some("true"), "{name}"); } + assert_eq!(field(connection(&collected, "SSL DB"), "tlsSettingsUnread"), None); } #[test] From adc77f99ed06fb3bc0eac012632470cf36bfc511 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 16:27:16 +0700 Subject: [PATCH 04/12] fix(core): read the TLS, certificate and tunnel settings other apps really save --- .../foreign-import/src/apps/beekeeper/mod.rs | 70 +++-- .../src/apps/datagrip/data_sources.rs | 90 ++++-- .../src/apps/dbeaver/credentials.rs | 36 ++- .../foreign-import/src/apps/dbeaver/mod.rs | 258 +++++++++++++++--- core/crates/foreign-import/tests/beekeeper.rs | 31 ++- core/crates/foreign-import/tests/datagrip.rs | 13 + core/crates/foreign-import/tests/dbeaver.rs | 88 +++++- .../fixtures/beekeeper/tls_connections.sql | 10 + .../options/dataSources.local.xml | 22 ++ .../DataGrip2025.1/options/dataSources.xml | 29 ++ .../DataGrip2025.1/options/sshConfigs.xml | 7 + .../General/.dbeaver/credentials-config.json | Bin 416 -> 544 bytes .../.dbeaver/credentials-config.plain.json | 8 +- .../General/.dbeaver/data-sources.json | 195 ++++++++++++- 14 files changed, 756 insertions(+), 101 deletions(-) create mode 100644 core/crates/foreign-import/tests/fixtures/beekeeper/tls_connections.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.local.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/sshConfigs.xml diff --git a/core/crates/foreign-import/src/apps/beekeeper/mod.rs b/core/crates/foreign-import/src/apps/beekeeper/mod.rs index e666cda43d..8d26a8b5ca 100644 --- a/core/crates/foreign-import/src/apps/beekeeper/mod.rs +++ b/core/crates/foreign-import/src/apps/beekeeper/mod.rs @@ -119,6 +119,7 @@ fn record( .port .and_then(|port| u16::try_from(port).ok()) .unwrap_or_else(|| engine::default_port(&engine)); + let ssl = row.ssl.then(|| ssl(row, &engine)); let settings = ForeignConnection { name: if row.name.is_empty() { "Untitled".to_owned() @@ -135,7 +136,7 @@ fn record( username: row.username.clone().unwrap_or_default(), engine, ssh: row.ssh_enabled.then(|| ssh(row)), - ssl: row.ssl.then(|| ssl(row)), + ssl, color: map_color(row.label_color.as_deref()), additional_fields: BTreeMap::new(), }; @@ -209,20 +210,39 @@ fn map_ssh_auth(mode: Option<&str>) -> ForeignSshAuth { } } -fn ssl(row: &SavedConnectionRow) -> ForeignSsl { - let mode = if row.ssl_reject_unauthorized && !row.trust_server_certificate { - ForeignSslMode::VerifyIdentity - } else { - ForeignSslMode::Required - }; +fn ssl(row: &SavedConnectionRow, engine: &str) -> ForeignSsl { ForeignSsl { - mode, + mode: ssl_mode(row, engine), ca_certificate_path: row.ssl_ca_file.clone(), client_certificate_path: row.ssl_cert_file.clone(), client_key_path: row.ssl_key_file.clone(), } } +// What each Beekeeper connector hands its driver (`lib/db/clients/*/*Connector.ts`). The saved +// `trustServerCertificate` is SQL Server's alone, and TLS on replaces it there. +fn ssl_mode(row: &SavedConnectionRow, engine: &str) -> ForeignSslMode { + let file = |path: &Option| path.as_deref().is_some_and(|path| !path.is_empty()); + let (ca, cert, key) = ( + file(&row.ssl_ca_file), + file(&row.ssl_cert_file), + file(&row.ssl_key_file), + ); + let verifies = match engine { + "SQL Server" => !(ca && cert && key) || row.ssl_reject_unauthorized, + // With no certificate file these connectors never check the server. + "PostgreSQL" | "Redshift" | "CockroachDB" | "MySQL" | "MariaDB" | "TiDB" => { + row.ssl_reject_unauthorized && (ca || cert || key) + } + _ => row.ssl_reject_unauthorized, + }; + if verifies { + ForeignSslMode::VerifyIdentity + } else { + ForeignSslMode::Required + } +} + fn map_color(raw: Option<&str>) -> Option { match raw?.to_lowercase().as_str() { "red" => Some(ForeignColor::Red), @@ -267,7 +287,6 @@ struct SavedConnectionRow { ssl_cert_file: Option, ssl_key_file: Option, ssl_reject_unauthorized: bool, - trust_server_certificate: bool, ssh_enabled: bool, ssh_host: Option, ssh_port: Option, @@ -288,7 +307,7 @@ struct SavedConnectionRow { // A cloud workspace's rows have a positive workspaceId and belong to that workspace's server copy. const SAVED_CONNECTIONS_SQL: &str = " SELECT id, name, connectionType, host, port, username, defaultDatabase, password, - ssl, sslCaFile, sslCertFile, sslKeyFile, sslRejectUnauthorized, trustServerCertificate, + ssl, sslCaFile, sslCertFile, sslKeyFile, sslRejectUnauthorized, sshEnabled, sshHost, sshPort, sshUsername, sshMode, sshKeyfile, sshKeyfilePassword, sshPassword, sshBastionHost, sshBastionHostPort, sshBastionUsername, sshBastionMode, sshBastionKeyfile, labelColor, connectionFolderId @@ -323,22 +342,21 @@ fn saved_connection(row: &Row<'_>) -> SavedConnectionRow { ssl_cert_file: row.text(10), ssl_key_file: row.text(11), ssl_reject_unauthorized: flag(12), - trust_server_certificate: flag(13), - ssh_enabled: flag(14), - ssh_host: row.text(15), - ssh_port: row.optional_int64(16), - ssh_username: row.text(17), - ssh_mode: row.text(18), - ssh_keyfile: row.text(19), - ssh_keyfile_password: row.text(20), - ssh_password: row.text(21), - ssh_bastion_host: row.text(22), - ssh_bastion_port: row.optional_int64(23), - ssh_bastion_username: row.text(24), - ssh_bastion_mode: row.text(25), - ssh_bastion_keyfile: row.text(26), - label_color: row.text(27), - connection_folder_id: row.optional_int64(28), + ssh_enabled: flag(13), + ssh_host: row.text(14), + ssh_port: row.optional_int64(15), + ssh_username: row.text(16), + ssh_mode: row.text(17), + ssh_keyfile: row.text(18), + ssh_keyfile_password: row.text(19), + ssh_password: row.text(20), + ssh_bastion_host: row.text(21), + ssh_bastion_port: row.optional_int64(22), + ssh_bastion_username: row.text(23), + ssh_bastion_mode: row.text(24), + ssh_bastion_keyfile: row.text(25), + label_color: row.text(26), + connection_folder_id: row.optional_int64(27), } } diff --git a/core/crates/foreign-import/src/apps/datagrip/data_sources.rs b/core/crates/foreign-import/src/apps/datagrip/data_sources.rs index 50e14f2ade..0f662aec50 100644 --- a/core/crates/foreign-import/src/apps/datagrip/data_sources.rs +++ b/core/crates/foreign-import/src/apps/datagrip/data_sources.rs @@ -28,8 +28,26 @@ pub(crate) struct Fragment { pub jdbc_url: Option, pub username: Option, pub group_name: Option, - pub ssh: Option, - pub ssl: Option, + pub ssh: Section, + pub ssl: Section, +} + +// The local file can turn off a section the shared file turns on; a file without the section leaves it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) enum Section { + #[default] + Absent, + Disabled, + Enabled(T), +} + +impl Section { + fn enabled(&self) -> Option { + match self { + Self::Enabled(value) => Some(value.clone()), + Self::Absent | Self::Disabled => None, + } + } } // Only an enabled `ssh-properties` becomes a reference. @@ -70,8 +88,12 @@ impl Fragment { take(&mut self.jdbc_url, other.jdbc_url); take(&mut self.username, other.username); take(&mut self.group_name, other.group_name); - take(&mut self.ssh, other.ssh); - take(&mut self.ssl, other.ssl); + if other.ssh != Section::Absent { + self.ssh = other.ssh; + } + if other.ssl != Section::Absent { + self.ssl = other.ssl; + } } pub(crate) fn resolved(&self) -> Option { @@ -84,8 +106,8 @@ impl Fragment { jdbc_url, username: self.username.clone().unwrap_or_default(), group_name: self.group_name.clone(), - ssh: self.ssh.clone(), - ssl: self.ssl.clone(), + ssh: self.ssh.enabled(), + ssl: self.ssl.enabled(), }) } } @@ -135,14 +157,16 @@ fn parse_fragment(element: Node<'_, '_>, uuid: &str, home: &Path) -> Fragment { } } -fn parse_ssh_reference(element: Node<'_, '_>) -> Option { - let ssh = xml::child(element, "ssh-properties")?; +fn parse_ssh_reference(element: Node<'_, '_>) -> Section { + let Some(ssh) = xml::child(element, "ssh-properties") else { + return Section::Absent; + }; let enabled = xml::child_text(ssh, "enabled").or_else(|| ssh.attribute("enabled").map(str::to_owned)); if enabled.as_deref() != Some("true") { - return None; + return Section::Disabled; } let config_id = xml::child_text(ssh, "ssh-config-id").or_else(|| ssh.attribute("ssh-config-id").map(str::to_owned)); - Some(SshReference { + Section::Enabled(SshReference { config_id: non_empty(config_id), inline_host: ssh.attribute("host").map(str::to_owned), inline_port: ssh.attribute("port").and_then(|port| port.parse().ok()), @@ -153,13 +177,15 @@ fn parse_ssh_reference(element: Node<'_, '_>) -> Option { }) } -fn parse_ssl_properties(element: Node<'_, '_>, home: &Path) -> Option { - let ssl = xml::child(element, "ssl-config")?; +fn parse_ssl_properties(element: Node<'_, '_>, home: &Path) -> Section { + let Some(ssl) = xml::child(element, "ssl-config") else { + return Section::Absent; + }; if xml::child_text(ssl, "enabled").as_deref() != Some("true") { - return None; + return Section::Disabled; } let cert_path = |name: &str| non_empty(xml::child_text(ssl, name)).map(|path| expand(&path, home)); - Some(SslProperties { + Section::Enabled(SslProperties { mode: xml::child_text(ssl, "mode"), ca_cert_path: cert_path("ca-cert"), client_cert_path: cert_path("client-cert"), @@ -220,14 +246,44 @@ mod tests { "", "" )); - let first = parsed[0].ssh.clone().expect("ssh"); + let first = parsed[0].ssh.enabled().expect("ssh"); assert_eq!(first.config_id.as_deref(), Some("S")); - let second = parsed[1].ssh.clone().expect("ssh"); + let second = parsed[1].ssh.enabled().expect("ssh"); assert_eq!(second.config_id, None); assert_eq!(second.inline_host.as_deref(), Some("b")); assert_eq!(second.inline_port, Some(2200)); assert_eq!(second.inline_user.as_deref(), Some("u")); - assert_eq!(parsed[2].ssh, None); + assert_eq!(parsed[2].ssh, Section::Disabled); + } + + #[test] + fn a_section_the_local_file_disables_turns_off_the_shared_one_and_an_absent_one_keeps_it() { + let shared = concat!( + "", + "pgjdbc:postgresql://h/db", + "trueS", + "trueREQUIRE", + "pgjdbc:postgresql://h/db", + "trueS", + "trueREQUIRE", + "" + ); + let local = concat!( + "", + "falseS", + "false", + "me", + "" + ); + let mut merged = fragments(shared); + for (fragment, local) in merged.iter_mut().zip(fragments(local)) { + fragment.merge(local); + } + let off = merged[0].resolved().expect("a data source"); + assert_eq!((off.ssh, off.ssl), (None, None)); + let kept = merged[1].resolved().expect("a data source"); + assert_eq!(kept.ssh.and_then(|ssh| ssh.config_id).as_deref(), Some("S")); + assert_eq!(kept.ssl.and_then(|ssl| ssl.mode).as_deref(), Some("REQUIRE")); } #[test] diff --git a/core/crates/foreign-import/src/apps/dbeaver/credentials.rs b/core/crates/foreign-import/src/apps/dbeaver/credentials.rs index 97874bb8bc..ac9df1871e 100644 --- a/core/crates/foreign-import/src/apps/dbeaver/credentials.rs +++ b/core/crates/foreign-import/src/apps/dbeaver/credentials.rs @@ -39,16 +39,17 @@ impl Credentials { self.entries.get(id)?.get("#connection")?.get("user")?.as_str() } + pub(super) fn tunnel_username(&self, id: &str) -> Option<&str> { + tunnel_field(self.entries.get(id)?, "user") + } + pub(super) fn secrets(&self, id: &str) -> Option { let entry = self.entries.get(id)?; let password = entry .get("#connection") .and_then(|block| block.get("password")) .and_then(Value::as_str); - let ssh_password = entry - .get("ssh_tunnel") - .and_then(|block| block.get("password")) - .and_then(Value::as_str); + let ssh_password = tunnel_field(entry, "password"); if password.is_none() && ssh_password.is_none() { return None; } @@ -60,6 +61,13 @@ impl Credentials { } } +// DBeaver keys a network handler's secrets `network/`; the bare id is the macOS port's guess. +fn tunnel_field<'a>(entry: &'a Value, field: &str) -> Option<&'a str> { + ["network/ssh_tunnel", "ssh_tunnel"] + .into_iter() + .find_map(|node| entry.get(node)?.get(field)?.as_str()) +} + fn secret(value: &str) -> SecretString { SecretString::from(value.to_owned()) } @@ -154,6 +162,26 @@ mod tests { assert!(credentials.secrets("missing").is_none()); } + #[test] + fn a_tunnel_s_user_and_password_come_from_its_network_node_before_the_bare_one() { + let directory = tempfile::tempdir().expect("a temp dir"); + let path = directory.path().join("credentials-config.json"); + let plain = br##"{ + "network": {"network/ssh_tunnel": {"user": "deploy", "password": "new"}, "ssh_tunnel": {"password": "old"}}, + "mixed": {"network/ssh_tunnel": {"user": "deploy"}, "ssh_tunnel": {"password": "old"}} + }"##; + fs::write(&path, encrypt(plain, [5; 16])).expect("a file"); + let credentials = Credentials::load(&path); + + let network = credentials.secrets("network").expect("secrets"); + assert_eq!(network.ssh_password.as_ref().map(|s| s.expose_secret()), Some("new")); + assert!(network.password.is_none()); + assert_eq!(credentials.tunnel_username("network"), Some("deploy")); + let mixed = credentials.secrets("mixed").expect("secrets"); + assert_eq!(mixed.ssh_password.as_ref().map(|s| s.expose_secret()), Some("old")); + assert_eq!(credentials.username("network"), None); + } + #[test] fn a_missing_or_undecryptable_file_reads_as_empty() { let directory = tempfile::tempdir().expect("a temp dir"); diff --git a/core/crates/foreign-import/src/apps/dbeaver/mod.rs b/core/crates/foreign-import/src/apps/dbeaver/mod.rs index 850e9d387d..244de4d551 100644 --- a/core/crates/foreign-import/src/apps/dbeaver/mod.rs +++ b/core/crates/foreign-import/src/apps/dbeaver/mod.rs @@ -67,7 +67,12 @@ impl AppReader for DBeaver { for source in &sources { for (id, connection) in &source.connections { check_cancel(cancel)?; - let settings = settings(id, connection, source.credentials.username(id)); + let settings = settings( + id, + connection, + source.credentials.username(id), + source.credentials.tunnel_username(id), + ); let credentials = request .include_passwords .then(|| source.credentials.secrets(id)) @@ -212,22 +217,33 @@ fn tunnel_secret_by_auth(mut credentials: ForeignCredentials, settings: &Foreign credentials } -fn settings(id: &str, connection: &Value, credential_username: Option<&str>) -> ForeignConnection { +fn settings( + id: &str, + connection: &Value, + credential_username: Option<&str>, + tunnel_username: Option<&str>, +) -> ForeignConnection { let name = connection.get("name").and_then(Value::as_str).unwrap_or(id).to_owned(); let engine = map_provider(connection.get("provider").and_then(Value::as_str).unwrap_or("")); let config = connection.get("configuration").unwrap_or(&NOTHING); let text = |key: &str| config.get(key).and_then(Value::as_str); + // In URL mode DBeaver connects with the URL as written, and the manual fields are leftovers. + let url_mode = text("configurationType") == Some("URL"); // A connection given as a URL keeps only what the URL names, never the URL itself, which can // hold a password. The macOS port stored the whole URL as the database name. let endpoint = text("url") - .filter(|_| text("host").is_none_or(str::is_empty)) + .filter(|_| url_mode || text("host").is_none_or(str::is_empty)) .and_then(|url| jdbc::parse(url, &jdbc::subprotocol(url))); - let host = text("host") + let from_url = url_mode && endpoint.is_some(); + let manual = |key: &str| config.get(key).filter(|_| !from_url); + let host = manual("host") + .and_then(Value::as_str) .filter(|host| !host.is_empty()) .map(str::to_owned) .or_else(|| endpoint.as_ref().map(|endpoint| endpoint.host.clone())) .unwrap_or_else(|| "localhost".to_owned()); - let database = text("database") + let database = manual("database") + .and_then(Value::as_str) .map(str::to_owned) .or_else(|| endpoint.as_ref().map(|endpoint| endpoint.database.clone())) .unwrap_or_default(); @@ -242,20 +258,25 @@ fn settings(id: &str, connection: &Value, credential_username: Option<&str>) -> .unwrap_or(""); // Read whether or not the host came from the URL: a seed list and an auth database live only there. let mut additional_fields = text("url").map(jdbc::url_fields).unwrap_or_default(); - // A URL-only connection keeps the TLS its URL asks for, which the macOS port dropped. - let ssl = match ssl(config) { - Some(ssl) => Some(ssl), - None => text("url").and_then(|url| jdbc::url_ssl_or_mark(url, &mut additional_fields)), + let ssl = match handler_tls(config) { + HandlerTls::On(ssl) => Some(ssl), + tls => { + if matches!(tls, HandlerTls::Unread) { + additional_fields.insert(crate::TLS_SETTINGS_UNREAD.to_owned(), "true".to_owned()); + } + // A URL-only connection keeps the TLS its URL asks for, which the macOS port dropped. + text("url").and_then(|url| jdbc::url_ssl_or_mark(url, &mut additional_fields)) + } }; ForeignConnection { name, host, - port: port(config.get("port")) + port: port(manual("port")) .or_else(|| endpoint.as_ref().and_then(|endpoint| endpoint.port)) .unwrap_or_else(|| engine::default_port(&engine)), database, username: username.to_owned(), - ssh: ssh(config), + ssh: ssh(config, tunnel_username), ssl, color: color(config), additional_fields, @@ -280,7 +301,7 @@ fn map_provider(provider: &str) -> String { known.to_owned() } -fn ssh(config: &Value) -> Option { +fn ssh(config: &Value, credential_username: Option<&str>) -> Option { let tunnel = config .get("handlers")? .get("ssh_tunnel") @@ -307,42 +328,122 @@ fn ssh(config: &Value) -> Option { } else { String::new() }; + // DBeaver keeps the tunnel's user with its secrets, or beside its properties in an older file. + let username = [ + text("username"), + tunnel.get("user").and_then(Value::as_str), + credential_username, + ] + .into_iter() + .flatten() + .find(|username| !username.is_empty()) + .unwrap_or(""); Some(ForeignSsh { host: text("host").unwrap_or("").to_owned(), port: port(property("port")), - username: text("username").unwrap_or("").to_owned(), + username: username.to_owned(), auth, private_key_path, jump_hosts: Vec::new(), }) } -fn ssl(config: &Value) -> Option { - let handler = config - .get("handlers")? - .get("ssl") - .filter(|handler| handler.is_object())?; - if !handler.get("enabled").and_then(boolean).unwrap_or(false) { - return None; +enum HandlerTls { + Absent, + Off, + On(ForeignSsl), + Unread, +} + +// DBeaver names the TLS handler per engine: `postgre_ssl`, `mysql_ssl`, `mssql_ssl`, `clickhouse-ssl`. +fn is_tls_handler(id: &str) -> bool { + id == "ssl" || id.ends_with("_ssl") || id.ends_with("-ssl") +} + +fn handler_tls(config: &Value) -> HandlerTls { + let handlers = config.get("handlers").cloned().and_then(object).unwrap_or_default(); + let mut outcome = HandlerTls::Absent; + for (id, handler) in &handlers { + if !is_tls_handler(id) || !handler.get("enabled").and_then(boolean).unwrap_or(false) { + continue; + } + let properties = handler.get("properties").unwrap_or(&NOTHING); + match tls_mode(id, properties) { + Some(ForeignSslMode::Disabled) => { + if matches!(outcome, HandlerTls::Absent) { + outcome = HandlerTls::Off; + } + } + Some(mode) => return HandlerTls::On(tls_settings(mode, properties)), + None => outcome = HandlerTls::Unread, + } } + outcome +} + +// The mode each engine's handler hands its driver; None when the handler keeps no mode this can read. +fn tls_mode(id: &str, properties: &Value) -> Option { let text = |key: &str| { - handler - .get("properties") - .and_then(|properties| properties.get(key)) + properties + .get(key) .and_then(Value::as_str) + .filter(|text| !text.is_empty()) }; - let mode = match text("sslMode").unwrap_or("") { - "require" => ForeignSslMode::Required, - "verify-ca" => ForeignSslMode::VerifyCa, - "verify-full" => ForeignSslMode::VerifyIdentity, - _ => ForeignSslMode::Preferred, + let flag = |key: &str| { + properties.get(key).is_some_and(|value| { + value.as_bool().unwrap_or(false) || value.as_str().is_some_and(|text| text.eq_ignore_ascii_case("true")) + }) }; - Some(ForeignSsl { + match id { + // The PostgreSQL driver takes `ssl=true` with no mode as verify-full. + "postgre_ssl" => text("sslMode").map_or(Some(ForeignSslMode::VerifyIdentity), named_mode), + // Connector/J's legacy switches: checking the certificate is verify-ca. + "mysql_ssl" => Some(if flag("ssl.verify.server") { + ForeignSslMode::VerifyCa + } else if flag("ssl.require") { + ForeignSslMode::Required + } else { + ForeignSslMode::Preferred + }), + // The ClickHouse driver checks the server unless the mode is NONE. + "clickhouse-ssl" => match text("ssl.mode").map(str::to_ascii_uppercase).as_deref() { + None | Some("STRICT") => Some(ForeignSslMode::VerifyIdentity), + Some("NONE") => Some(ForeignSslMode::Required), + Some(_) => None, + }, + _ => text("sslMode").and_then(named_mode), + } +} + +fn named_mode(mode: &str) -> Option { + match mode.to_ascii_lowercase().as_str() { + "disable" => Some(ForeignSslMode::Disabled), + "allow" | "prefer" => Some(ForeignSslMode::Preferred), + "require" => Some(ForeignSslMode::Required), + "verify-ca" => Some(ForeignSslMode::VerifyCa), + "verify-full" => Some(ForeignSslMode::VerifyIdentity), + _ => None, + } +} + +// DBeaver's own keys first, then the PostgreSQL handler's older ones, then the macOS port's. +fn tls_settings(mode: ForeignSslMode, properties: &Value) -> ForeignSsl { + let path = |keys: [&str; 3]| { + keys.into_iter() + .find_map(|key| { + properties + .get(key) + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + }) + .map(str::to_owned) + }; + ForeignSsl { mode, - ca_certificate_path: text("caCertPath").map(str::to_owned), - client_certificate_path: text("clientCertPath").map(str::to_owned), - client_key_path: text("clientKeyPath").map(str::to_owned), - }) + ca_certificate_path: path(["ssl.ca.cert", "rootCert", "caCertPath"]), + client_certificate_path: path(["ssl.client.cert", "clientCert", "clientCertPath"]), + client_key_path: path(["ssl.client.key", "clientKey", "clientKeyPath"]), + } } // DBeaver keeps any colour the user picks as `r,g,b`; only the clear hues map to a TablePro colour. @@ -416,7 +517,7 @@ mod tests { r#"{"provider": "postgresql", "name": "cloud", "configuration": {"url": "jdbc:postgresql://admin:hunter2@db.cloud.example:6543/app?password=hunter2&sslmode=require"}}"#, ); - let settings = settings("cloud-id", &connection, None); + let settings = settings("cloud-id", &connection, None, None); assert_eq!( (settings.host.as_str(), settings.port, settings.database.as_str()), ("db.cloud.example", 6543, "app") @@ -433,7 +534,7 @@ mod tests { r#"{"provider": "mongodb", "name": "atlas", "configuration": {"url": "mongodb+srv://cluster0.example.net/app"}}"#, ); - let settings = settings("atlas-id", &connection, None); + let settings = settings("atlas-id", &connection, None, None); assert_eq!(settings.host, "cluster0.example.net"); assert_eq!( settings.additional_fields.get("mongoUseSrv").map(String::as_str), @@ -449,7 +550,7 @@ mod tests { "configuration": {"host": "mongo.example.com", "port": "27017", "database": "shop", "url": "mongodb://mongo.example.com:27017/shop?authSource=admin"}}"#, ); - let settings = settings("mongo-id", &connection, None); + let settings = settings("mongo-id", &connection, None, None); assert_eq!(settings.host, "mongo.example.com"); assert_eq!( settings.additional_fields.get("mongoAuthSource").map(String::as_str), @@ -466,7 +567,7 @@ mod tests { "configuration": {"host": "db", "handlers": {"ssh_tunnel": {"enabled": true, "properties": {"host": "bastion", "authType": "PUBLIC_KEY", "keyPath": "~/.ssh/id"}}}}}"#, ); - let settings = settings("tunnelled-id", &connection, None); + let settings = settings("tunnelled-id", &connection, None, None); let credentials = ForeignCredentials { ssh_password: Some(SecretString::from("phrase")), ..ForeignCredentials::default() @@ -485,10 +586,91 @@ mod tests { r#"{"provider": "postgresql", "name": "verified", "configuration": {"url": "jdbc:postgresql://db.example.com/app?sslmode=verify-full"}}"#, ); - let settings = settings("verified-id", &connection, None); + let settings = settings("verified-id", &connection, None, None); assert_eq!(settings.ssl.map(|ssl| ssl.mode), Some(ForeignSslMode::VerifyIdentity)); } + #[test] + fn a_url_mode_url_without_a_port_takes_the_default_port_not_the_stale_one() { + let connection = parsed( + r#"{"provider": "postgresql", "name": "url", + "configuration": {"host": "old", "port": "6000", "database": "old", + "url": "jdbc:postgresql://new.example.com/app", "configurationType": "URL"}}"#, + ); + let settings = settings("url-id", &connection, None, None); + assert_eq!( + (settings.host.as_str(), settings.port, settings.database.as_str()), + ("new.example.com", 5432, "app") + ); + } + + #[test] + fn a_url_mode_url_that_does_not_parse_leaves_the_manual_fields() { + let connection = parsed( + r#"{"provider": "postgresql", "name": "url", + "configuration": {"host": "db", "port": "6000", "url": "not a url", "configurationType": "URL"}}"#, + ); + let settings = settings("url-id", &connection, None, None); + assert_eq!((settings.host.as_str(), settings.port), ("db", 6000)); + } + + #[test] + fn the_older_tls_handler_and_certificate_names_still_read() { + let tls = |handlers: &str| handler_tls(&parsed(&format!(r#"{{"handlers": {handlers}}}"#))); + let HandlerTls::On(legacy) = tls(r#"{"ssl": {"enabled": true, "properties": {"sslMode": "verify-ca", + "caCertPath": "/ca.pem", "clientCertPath": "/c.pem", "clientKeyPath": "/k.pem"}}}"#) + else { + panic!("TLS"); + }; + assert_eq!(legacy.mode, ForeignSslMode::VerifyCa); + assert_eq!( + ( + legacy.ca_certificate_path.as_deref(), + legacy.client_certificate_path.as_deref(), + legacy.client_key_path.as_deref() + ), + (Some("/ca.pem"), Some("/c.pem"), Some("/k.pem")) + ); + let HandlerTls::On(postgres) = tls(r#"{"postgre_ssl": {"enabled": true, "properties": {"sslMode": "", + "ssl.ca.cert": "", "rootCert": "/root.crt", "clientCert": "/old.crt"}}}"#) + else { + panic!("TLS"); + }; + assert_eq!(postgres.mode, ForeignSslMode::VerifyIdentity); + assert_eq!(postgres.ca_certificate_path.as_deref(), Some("/root.crt")); + assert_eq!(postgres.client_certificate_path.as_deref(), Some("/old.crt")); + } + + #[test] + fn a_tls_handler_with_no_mode_this_reads_is_unread() { + let tls = |handlers: &str| handler_tls(&parsed(&format!(r#"{{"handlers": {handlers}}}"#))); + assert!(matches!(tls(r#"{"ssl": {"enabled": true}}"#), HandlerTls::Unread)); + assert!(matches!( + tls(r#"{"databend-ssl": {"enabled": true}}"#), + HandlerTls::Unread + )); + assert!(matches!( + tls(r#"{"clickhouse-ssl": {"enabled": true, "properties": {"ssl.mode": "LAX"}}}"#), + HandlerTls::Unread + )); + assert!(matches!( + tls(r#"{"postgre_ssl": {"enabled": true, "properties": {"sslMode": "DISABLE"}}}"#), + HandlerTls::Off + )); + assert!(matches!( + tls(r#"{"postgre_ssl": {"enabled": false}, "ssh_tunnel": {"enabled": true}}"#), + HandlerTls::Absent + )); + } + + #[test] + fn mysql_s_switches_read_as_booleans_or_strings() { + let mode = |properties: &str| tls_mode("mysql_ssl", &parsed(properties)); + assert_eq!(mode(r#"{"ssl.verify.server": true}"#), Some(ForeignSslMode::VerifyCa)); + assert_eq!(mode(r#"{"ssl.require": "TRUE"}"#), Some(ForeignSslMode::Required)); + assert_eq!(mode(r#"{"ssl.require": "false"}"#), Some(ForeignSslMode::Preferred)); + } + fn parsed(text: &str) -> Value { serde_json::from_str(text).expect("JSON") } diff --git a/core/crates/foreign-import/tests/beekeeper.rs b/core/crates/foreign-import/tests/beekeeper.rs index 3520d41595..ec41d617d6 100644 --- a/core/crates/foreign-import/tests/beekeeper.rs +++ b/core/crates/foreign-import/tests/beekeeper.rs @@ -490,7 +490,7 @@ fn ssh_ssl_colors_and_defaults_map_as_on_macos() { assert!(ssh.jump_hosts.is_empty()); assert_eq!( untitled.settings.ssl.as_ref().map(|ssl| ssl.mode), - Some(ForeignSslMode::Required) + Some(ForeignSslMode::VerifyIdentity) ); let lake = &collected.connections[3]; @@ -501,6 +501,35 @@ fn ssh_ssl_colors_and_defaults_map_as_on_macos() { assert_eq!(ssl.ca_certificate_path, None); } +#[test] +fn tls_verification_follows_what_each_engine_s_connector_does() { + let (_home, importer) = mac_home(&["connections.sql", "queries.sql", "tls_connections.sql"]); + + let collected = collect(&importer, &CONNECTIONS_ONLY); + + let modes = [ + ("PG no files", ForeignSslMode::Required), + ("PG with CA", ForeignSslMode::VerifyIdentity), + ("PG unverified", ForeignSslMode::Required), + ("MySQL no files", ForeignSslMode::Required), + ("SQL Server", ForeignSslMode::VerifyIdentity), + ("SQL Server trusted", ForeignSslMode::Required), + ("Redis", ForeignSslMode::VerifyIdentity), + ]; + for (name, mode) in modes { + let connection = collected + .connections + .iter() + .find(|connection| connection.settings.name == name) + .expect("a connection"); + assert_eq!( + connection.settings.ssl.as_ref().map(|ssl| ssl.mode), + Some(mode), + "{name}" + ); + } +} + #[test] fn a_cancelled_import_stops() { let (_home, importer) = mac_home(FULL_DATABASE); diff --git a/core/crates/foreign-import/tests/datagrip.rs b/core/crates/foreign-import/tests/datagrip.rs index d5a73e9caa..5915477bd2 100644 --- a/core/crates/foreign-import/tests/datagrip.rs +++ b/core/crates/foreign-import/tests/datagrip.rs @@ -242,6 +242,19 @@ fn disabled_ssh_properties_give_no_tunnel() { assert_eq!(connection(&import, "ssh_off").settings.ssh, None); } +#[test] +fn ssh_and_ssl_the_local_file_disables_stay_off_though_the_shared_file_enables_them() { + let import = collect(&mac("local-disables", Arc::default()), request(false, false)); + let off = &connection(&import, "turned_off").settings; + assert_eq!((&off.ssh, &off.ssl), (&None, &None)); + let on = &connection(&import, "left_on").settings; + assert_eq!( + on.ssh.as_ref().map(|ssh| ssh.host.as_str()), + Some("bastion.example.com") + ); + assert_eq!(on.ssl.as_ref().map(|ssl| ssl.mode), Some(ForeignSslMode::Required)); +} + #[test] fn an_ssh_password_is_imported_from_c_kdbx() { let import = collect(&mac("connections", Arc::default()), request(true, false)); diff --git a/core/crates/foreign-import/tests/dbeaver.rs b/core/crates/foreign-import/tests/dbeaver.rs index 40391a1791..c3a5471852 100644 --- a/core/crates/foreign-import/tests/dbeaver.rs +++ b/core/crates/foreign-import/tests/dbeaver.rs @@ -14,7 +14,7 @@ use secrecy::ExposeSecret; use tablepro_foreign_import::{ CollectedConnection, CollectedImport, CollectedSavedQuery, ForeignApp, ForeignAppDirs, ForeignColor, ForeignHost, ForeignImportError, ForeignImporter, ForeignPlatform, ForeignSshAuth, ForeignSslMode, ImportRequest, Inventory, - PasswordOutcome, PlainHost, SavedQueryContent, + PasswordOutcome, PlainHost, SavedQueryContent, TLS_SETTINGS_UNREAD, }; use tokio_util::sync::CancellationToken; @@ -22,7 +22,7 @@ const SYNC_LIMIT: u64 = 900_000; const CONNECTIONS_ONLY: ImportRequest = request(false, false); const WITH_PASSWORDS: ImportRequest = request(true, false); const WITH_SAVED_QUERIES: ImportRequest = request(false, true); -const MACOS_CONNECTION_COUNT: usize = 48; +const MACOS_CONNECTION_COUNT: usize = 57; const fn request(include_passwords: bool, include_saved_queries: bool) -> ImportRequest { ImportRequest { @@ -298,6 +298,28 @@ fn an_ssh_port_reads_from_a_string() { assert_eq!(ssh.port, Some(2222)); } +#[test] +fn a_tunnel_without_a_user_in_its_settings_takes_the_one_in_the_credentials_file() { + for request in [CONNECTIONS_ONLY, WITH_PASSWORDS] { + let import = collect(&macos(), request); + let ssh = connection(&import, "SSH user from credentials") + .settings + .ssh + .as_ref() + .expect("a tunnel"); + assert_eq!(ssh.username, "tunnel-user"); + } + let import = collect(&macos(), WITH_PASSWORDS); + let credentials = connection(&import, "SSH user from credentials") + .credentials + .as_ref() + .expect("credentials"); + assert_eq!( + credentials.ssh_password.as_ref().map(|secret| secret.expose_secret()), + Some("tunnel-secret") + ); +} + #[test] fn no_ssh_tunnel_without_its_handler() { let import = collect(&macos(), CONNECTIONS_ONLY); @@ -458,13 +480,13 @@ fn connections_are_keyed_by_their_data_source_id_and_sorted_by_name_then_id() { } #[test] -fn ssl_modes_map_and_an_empty_mode_is_preferred() { +fn ssl_modes_map_and_an_empty_postgres_mode_verifies_the_server() { let import = collect(&macos(), CONNECTIONS_ONLY); let modes = [ ("SSL Require", ForeignSslMode::Required), ("SSL Verify CA", ForeignSslMode::VerifyCa), ("SSL Verify Full", ForeignSslMode::VerifyIdentity), - ("SSL Default", ForeignSslMode::Preferred), + ("SSL Default", ForeignSslMode::VerifyIdentity), ]; for (name, mode) in modes { let ssl = connection(&import, name).settings.ssl.as_ref().expect("SSL"); @@ -472,6 +494,64 @@ fn ssl_modes_map_and_an_empty_mode_is_preferred() { } } +#[test] +fn each_engine_s_tls_handler_is_read_by_its_own_name() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let modes = [ + ("MySQL SSL required", ForeignSslMode::Required), + ("MySQL SSL verified", ForeignSslMode::VerifyCa), + ("ClickHouse SSL", ForeignSslMode::Required), + ]; + for (name, mode) in modes { + let settings = &connection(&import, name).settings; + assert_eq!(settings.ssl.as_ref().map(|ssl| ssl.mode), Some(mode), "{name}"); + assert!(!settings.additional_fields.contains_key(TLS_SETTINGS_UNREAD), "{name}"); + } + let verified = connection(&import, "MySQL SSL verified") + .settings + .ssl + .as_ref() + .expect("SSL"); + assert_eq!(verified.ca_certificate_path.as_deref(), Some("/certs/mysql-ca.pem")); +} + +#[test] +fn a_tls_handler_whose_mode_is_not_read_marks_the_connection_instead_of_turning_tls_off() { + let import = collect(&macos(), CONNECTIONS_ONLY); + for name in ["SQL Server SSL", "SSL Unknown Mode"] { + let settings = &connection(&import, name).settings; + assert_eq!(settings.ssl, None, "{name}"); + assert_eq!( + settings.additional_fields.get(TLS_SETTINGS_UNREAD).map(String::as_str), + Some("true"), + "{name}" + ); + } +} + +#[test] +fn a_tls_handler_set_to_disable_turns_tls_off() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let settings = &connection(&import, "SSL Off").settings; + assert_eq!(settings.ssl, None); + assert!(!settings.additional_fields.contains_key(TLS_SETTINGS_UNREAD)); +} + +#[test] +fn in_url_mode_the_url_names_the_server_over_leftover_manual_fields() { + let import = collect(&macos(), CONNECTIONS_ONLY); + let url = &connection(&import, "URL mode").settings; + assert_eq!( + (url.host.as_str(), url.port, url.database.as_str()), + ("new.example.com", 6543, "current") + ); + let manual = &connection(&import, "Manual mode").settings; + assert_eq!( + (manual.host.as_str(), manual.port, manual.database.as_str()), + ("manual.example.com", 5433, "manual") + ); +} + #[test] fn ssl_certificate_paths_are_kept() { let import = collect(&macos(), CONNECTIONS_ONLY); diff --git a/core/crates/foreign-import/tests/fixtures/beekeeper/tls_connections.sql b/core/crates/foreign-import/tests/fixtures/beekeeper/tls_connections.sql new file mode 100644 index 0000000000..16cb2b64f5 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/beekeeper/tls_connections.sql @@ -0,0 +1,10 @@ +-- Beekeeper saves sslRejectUnauthorized and trustServerCertificate as 1 unless the user turns them off. +INSERT INTO saved_connection (id, name, connectionType, host, ssl, sslCaFile, sslCertFile, sslKeyFile, + sslRejectUnauthorized, trustServerCertificate) VALUES + (11, 'PG no files', 'postgresql', 'pg', 1, NULL, NULL, NULL, 1, 1), + (12, 'PG with CA', 'postgresql', 'pg', 1, '/certs/ca.pem', NULL, NULL, 1, 1), + (13, 'PG unverified', 'postgresql', 'pg', 1, '/certs/ca.pem', NULL, NULL, 0, 1), + (14, 'MySQL no files', 'mysql', 'my', 1, NULL, NULL, NULL, 1, 1), + (15, 'SQL Server', 'sqlserver', 'ms', 1, NULL, NULL, NULL, 0, 1), + (16, 'SQL Server trusted', 'sqlserver', 'ms', 1, '/certs/ca.pem', '/certs/c.pem', '/certs/c.key', 0, 0), + (17, 'Redis', 'redis', 'cache', 1, NULL, NULL, NULL, 1, 1); diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.local.xml b/core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.local.xml new file mode 100644 index 0000000000..2c00efc17b --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.local.xml @@ -0,0 +1,22 @@ + + + + + master_key + app + + + false + SSH1 + + + false + + + + master_key + app + + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.xml new file mode 100644 index 0000000000..8515416fe0 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/dataSources.xml @@ -0,0 +1,29 @@ + + + + + postgresql + jdbc:postgresql://db.example.com:5432/app + + true + SSH1 + + + true + REQUIRE + + + + postgresql + jdbc:postgresql://db.example.com:5432/app + + true + SSH1 + + + true + REQUIRE + + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/sshConfigs.xml b/core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/sshConfigs.xml new file mode 100644 index 0000000000..b779ec3b82 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/local-disables/macos/Library/Application Support/JetBrains/DataGrip2025.1/options/sshConfigs.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/.dbeaver/credentials-config.json b/core/crates/foreign-import/tests/fixtures/dbeaver/macos/Library/DBeaverData/workspace6/General/.dbeaver/credentials-config.json index bfe94e14324ce148ba7fe6244f72b93b6f1edc1e..41e346529b7869186847c8c36ea82d7cff71575f 100644 GIT binary patch literal 544 zcmV+*0^j`)9xXgeT4{KPmZh}A(&6w^RBLJW+sMW(t}wtUZJ~0d;fUm1qyzLqf|{80 zZ2)DjIJSdZpj;MC11b%0B~M{y&`%vWrCdGl@f|X|&zCND;R*B@1lMAaU)R!K{M=g& zWjI%A))?^NOt~F!Ua?I}UCg#jahoT_v*W5X+-FzcI2xd1Q8eCvh}-5_EJbdXfpX2- z2Rpyc@Kk=(4NeoqX*Pb64u3w$Q)<+)glXOiQq4WM01o~UWOW5`T>gtYn{CAfVcxU}F+^fmwtj48ex%y5mPKl4UaOCT@mkt(X#h2L-72>e zh0{Slm8fINlUB^fgg|9|N`VS`9x>k|x@TF^Yoj;i|AthUfXj4ufSPIwdyJY=Z`g&e zxg%>7RW9zJuGzI;K{lM4l-T_$cmJk2h~aFeGq>fb=q_J~6r< zhLg>%R7c)G8=QqtMJ3cM7^cN60uEd83-H8zO1GxZ!i!A&Qx42I$DU(=!oIgM`n{K;=IO$r-%)ts>B z3 Date: Tue, 3 Nov 2026 16:38:05 +0700 Subject: [PATCH 05/12] fix(core): read a URL's secure scheme and user parameter --- core/crates/foreign-import/src/apps/jdbc.rs | 46 ++++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/core/crates/foreign-import/src/apps/jdbc.rs b/core/crates/foreign-import/src/apps/jdbc.rs index 66b1d6d2f9..e4b5f36d87 100644 --- a/core/crates/foreign-import/src/apps/jdbc.rs +++ b/core/crates/foreign-import/src/apps/jdbc.rs @@ -105,11 +105,19 @@ pub(crate) fn url_tls(url: &str) -> UrlTls { } match parameters.iter().rev().find(|(key, _)| key == "sslmode") { Some((_, mode)) => named_mode(mode).map_or(UrlTls::Unread, UrlTls::Mode), - None if is_mongo_seed_list(url) => UrlTls::Mode("verify-full"), + None if is_mongo_seed_list(url) || secure_scheme(url) => UrlTls::Mode("verify-full"), None => UrlTls::None, } } +// `jdbc:clickhouse:https://` and the like: a scheme that is TLS by itself, verified by default. +fn secure_scheme(url: &str) -> bool { + url.split_once("://").is_some_and(|(scheme, _)| { + let scheme = scheme.to_lowercase(); + scheme.ends_with("https") || scheme.ends_with("+tls") || scheme.ends_with("+ssl") + }) +} + fn named_mode(mode: &str) -> Option<&'static str> { match mode { "disable" | "disabled" => Some("disabled"), @@ -123,6 +131,17 @@ fn named_mode(mode: &str) -> Option<&'static str> { } // Lowercased keys and values after `?` (and SQL Server's `;` properties), in order. +// One parameter as written, the last of its name, for a value whose case matters. +fn url_parameter(url: &str, name: &str) -> Option { + let (_, query) = url.split_once('?').or_else(|| url.split_once(';'))?; + query + .split(['&', ';']) + .filter_map(|pair| pair.split_once('=')) + .filter(|(key, _)| key.trim().eq_ignore_ascii_case(name)) + .map(|(_, value)| value.trim().to_owned()) + .next_back() +} + fn url_parameters(url: &str) -> Vec<(String, String)> { let Some((_, query)) = url.split_once('?').or_else(|| url.split_once(';')) else { return Vec::new(); @@ -173,10 +192,13 @@ fn parse_authority(body: &str) -> Option { let authority = strip_user_info(head); let first_host = authority.split(',').next().unwrap_or(authority); let (host, port) = parse_host_port(first_host); + // The user goes before `@`, or in the standard `user` parameter. let username = head .rsplit_once('@') .map(|(user_info, _)| user_info.split(':').next().unwrap_or_default()) .map(percent_decoded) + .filter(|user| !user.is_empty()) + .or_else(|| url_parameter(body, "user").map(|user| percent_decoded(&user))) .filter(|user| !user.is_empty()); // A database name escapes a space or a slash, and the drivers take the name as written. let database = percent_decoded(tail.unwrap_or_default()); @@ -233,7 +255,8 @@ fn parse_sql_server(body: &str) -> Option { .cloned() .unwrap_or_default(); } - endpoint(host, port, &database) + let username = url_parameter(body, "user").filter(|user| !user.is_empty()); + endpoint(host, port, &database).map(|endpoint| Endpoint { username, ..endpoint }) } // jdbc:oracle:thin:@host:port:SID, @//host:port/SERVICE and @host:port/SERVICE @@ -558,6 +581,25 @@ mod tests { ); } + #[test] + fn a_user_parameter_names_the_user_in_its_own_case() { + let endpoint = parse("jdbc:postgresql://db/app?user=Alice&sslmode=require", "postgresql").expect("an endpoint"); + assert_eq!(endpoint.username.as_deref(), Some("Alice")); + let server = parse("jdbc:sqlserver://db:1433;databaseName=app;user=Sales", "sqlserver").expect("an endpoint"); + assert_eq!(server.username.as_deref(), Some("Sales")); + } + + #[test] + fn a_secure_scheme_is_tls_verified_by_default() { + assert_eq!( + url_tls("jdbc:clickhouse:https://db.example:8443/default"), + UrlTls::Mode("verify-full") + ); + assert_eq!(url_tls("jdbc:clickhouse:http://db.example:8123/default"), UrlTls::None); + let endpoint = parse("jdbc:clickhouse:https://db.example:8443/default", "clickhouse").expect("an endpoint"); + assert_eq!((endpoint.host.as_str(), endpoint.port), ("db.example", Some(8443))); + } + #[test] fn an_escaped_database_name_is_decoded() { let endpoint = parse("jdbc:postgresql://db/team%20db?ssl=true", "postgresql").expect("an endpoint"); From 82158f0949ceec2604cf3e0b31cd5d18e3004a25 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 16:49:02 +0700 Subject: [PATCH 06/12] fix(core): import saved key passphrases and drop queries saved for a deleted connection --- .../foreign-import/src/apps/navicat/mod.rs | 23 +++++++++++---- core/crates/foreign-import/tests/navicat.rs | 28 +++++++++++++++++++ .../src/services/saved_connection_editor.rs | 27 +++++++++++++++++- .../src/ui/session_window/import_from_app.rs | 4 +-- 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/core/crates/foreign-import/src/apps/navicat/mod.rs b/core/crates/foreign-import/src/apps/navicat/mod.rs index c35c787c7e..a5e7d633d8 100644 --- a/core/crates/foreign-import/src/apps/navicat/mod.rs +++ b/core/crates/foreign-import/src/apps/navicat/mod.rs @@ -178,14 +178,18 @@ fn credentials(element: Node<'_, '_>) -> Option { }; let password = saved("SavePassword", "Password"); let ssh_password = saved("SSH_SavePassword", "SSH_Password"); - if password.is_none() && ssh_password.is_none() { - return None; - } - Some(ForeignCredentials { + // The macOS port left a saved key passphrase behind; the export spells it in either case. + let key_passphrase = if is_true(attribute_any_case(element, "SSH_SavePassPhrase")) { + decrypt_field(attribute_any_case(element, "SSH_PassPhrase")) + } else { + None + }; + ForeignCredentials { password, ssh_password, - key_passphrase: None, - }) + key_passphrase, + } + .non_empty() } fn decrypt_field(hex: &str) -> Option { @@ -228,6 +232,13 @@ fn attribute<'a>(element: Node<'a, '_>, name: &str) -> &'a str { element.attribute(name).unwrap_or_default() } +fn attribute_any_case<'a>(element: Node<'a, '_>, name: &str) -> &'a str { + element + .attributes() + .find(|attribute| attribute.name().eq_ignore_ascii_case(name)) + .map_or("", |attribute| attribute.value()) +} + fn is_true(value: &str) -> bool { value.to_lowercase() == "true" } diff --git a/core/crates/foreign-import/tests/navicat.rs b/core/crates/foreign-import/tests/navicat.rs index d416c56577..5eb30703ce 100644 --- a/core/crates/foreign-import/tests/navicat.rs +++ b/core/crates/foreign-import/tests/navicat.rs @@ -393,3 +393,31 @@ fn reading_stops_when_cancelled() { .collect_blocking(&request(true, false), &cancel); assert!(matches!(outcome, Err(ForeignImportError::Cancelled))); } + +#[test] +fn a_saved_key_passphrase_is_imported_in_either_spelling_and_alone() { + let dir = tempfile::tempdir().expect("a temp dir"); + let file = dir.path().join("keys.ncx"); + std::fs::write( + &file, + r#" + + + + + +"#, + ) + .expect("an export file"); + let import = import_of(file, ForeignPlatform::current(), &request(true, false)).expect("an import"); + let passphrase = |name: &str| { + connection(&import, name) + .credentials + .as_ref() + .and_then(|credentials| credentials.key_passphrase.as_ref()) + .map(|secret| secret.expose_secret().to_owned()) + }; + assert_eq!(passphrase("Lower").as_deref(), Some("This is a test")); + assert_eq!(passphrase("Upper").as_deref(), Some("This is a test")); + assert!(connection(&import, "Unsaved").credentials.is_none()); +} diff --git a/linux/crates/app/src/services/saved_connection_editor.rs b/linux/crates/app/src/services/saved_connection_editor.rs index c7cc9a8b8e..cdfd65265b 100644 --- a/linux/crates/app/src/services/saved_connection_editor.rs +++ b/linux/crates/app/src/services/saved_connection_editor.rs @@ -2,8 +2,11 @@ use std::sync::Arc; use tablepro_core::SafeModeLevel; use tablepro_core::credentials::{SecretError, SecretKind, SecretLookup, SecretVault}; -use tablepro_foreign_import::review::{AppliedImport, ImportPlan, ImportReview, apply_blocking}; +use tablepro_foreign_import::review::{ + AppliedImport, ImportPlan, ImportReview, QueryImportOutcome, apply_blocking, save_queries, +}; use tablepro_session::runtime::Tasks; +use tablepro_storage::saved_queries::SavedQueries; use tablepro_storage::{ ConnectionColor, ConnectionGroupName, ConnectionIcon, ConnectionStore, ConnectionTag, ConnectionTagName, SavedConnection, StorageError, @@ -261,6 +264,28 @@ impl SavedConnectionEditor { }) } + // A delete from another window purges a connection's queries once; any this import saves for + // it afterwards are removed again here. + pub async fn save_imported_queries(&self, applied: &AppliedImport, store: &SavedQueries) -> QueryImportOutcome { + let outcome = save_queries(applied, store).await; + if let Ok(list) = self.list().await { + let mut gone: Vec = applied + .queries + .iter() + .map(|query| query.query.connection_id) + .filter(|id| !list.iter().any(|saved| saved.id == *id)) + .collect(); + gone.sort_unstable(); + gone.dedup(); + for id in gone { + if let Err(error) = store.delete_for_connection(id).await { + tracing::warn!(%error, "queries imported for a deleted connection are still saved"); + } + } + } + outcome + } + /// A pick of the group it is already in writes nothing. pub async fn move_to_group(&self, id: Uuid, choice: GroupChoice) -> Result<(), SavedConnectionError> { let store = self.store.clone(); diff --git a/linux/crates/app/src/ui/session_window/import_from_app.rs b/linux/crates/app/src/ui/session_window/import_from_app.rs index e6e8d5f68f..3f4f5b0f10 100644 --- a/linux/crates/app/src/ui/session_window/import_from_app.rs +++ b/linux/crates/app/src/ui/session_window/import_from_app.rs @@ -1,5 +1,5 @@ use libadwaita::prelude::*; -use tablepro_foreign_import::review::{ImportPlan, ImportReview, QueryImportOutcome, save_queries}; +use tablepro_foreign_import::review::{ImportPlan, ImportReview, QueryImportOutcome}; use tablepro_storage::StorageError; use super::{SessionWindow, SessionWindowInput}; @@ -41,7 +41,7 @@ impl SessionWindow { Ok(imported) => { let queries = match &saved_queries { Some(store) if !imported.applied.queries.is_empty() => { - Some(save_queries(&imported.applied, store).await) + Some(editor.save_imported_queries(&imported.applied, store).await) } _ => None, }; From 62fad8f10d914ef69a0c671d23f5f93765c009d6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 18:33:18 +0700 Subject: [PATCH 07/12] fix: import the TLS other apps really used and fix the macOS importers' defects --- .../Import/CollectedImport.swift | 5 +- .../Import/ConnectionImportAnalyzer.swift | 3 +- .../ConnectionImportAnalyzerTests.swift | 25 + .../ForeignApp/BeekeeperStudioImporter.swift | 44 +- .../Export/ForeignApp/DBeaverImporter.swift | 445 +++++++++++++----- .../DataGrip/DataGripDataSourceParser.swift | 68 ++- .../DataGrip/JDBCConnectionString.swift | 250 +++++++--- .../Export/ForeignApp/DataGripImporter.swift | 82 +++- .../Export/ForeignApp/ForeignAppFile.swift | 38 ++ .../ForeignApp/ForeignAppImporter.swift | 12 + .../ForeignApp/ForeignBundleAssembly.swift | 7 +- .../JetBrains/JetBrainsCredentialStore.swift | 49 +- .../ForeignApp/JetBrains/KdbxDatabase.swift | 81 +++- .../ForeignApp/Navicat/NavicatImporter.swift | 12 +- .../BeekeeperSavedQueryReader.swift | 5 +- .../SavedQueries/DataGripConsoleReader.swift | 2 +- .../TablePlusFavoriteReader.swift | 5 +- .../Export/ForeignApp/SequelAceImporter.swift | 59 ++- .../Export/ForeignApp/TablePlusImporter.swift | 32 +- .../TablePro/Resources/Localizable.xcstrings | 3 + .../BeekeeperStudioImporterTests.swift | 88 ++++ .../ForeignApp/DBeaverImporterTests.swift | 322 ++++++++++++- .../ForeignApp/DataGripImporterTests.swift | 227 +++++++++ .../ForeignBundleAssemblyTests.swift | 13 + .../ForeignApp/ForeignImportTestSupport.swift | 4 + .../JDBCConnectionStringTests.swift | 115 +++++ .../JetBrainsCredentialStoreTests.swift | 36 ++ .../ForeignApp/KdbxDatabaseTests.swift | 57 +++ .../Services/ForeignApp/KdbxTestFixture.swift | 63 ++- .../ForeignApp/NavicatImporterTests.swift | 21 + .../ForeignApp/SequelAceImporterTests.swift | 67 ++- .../ForeignApp/TablePlusImporterTests.swift | 67 ++- apple/changelog.d/4015.fixed.md | 1 + .../foreign-import/src/apps/beekeeper/mod.rs | 39 +- .../src/apps/datagrip/data_sources.rs | 14 + .../foreign-import/src/apps/datagrip/mod.rs | 30 +- .../foreign-import/src/apps/dbeaver/mod.rs | 43 +- core/crates/foreign-import/src/apps/jdbc.rs | 72 ++- .../foreign-import/src/apps/sequel_ace/mod.rs | 35 +- .../src/apps/tableplus/connection.rs | 5 + core/crates/foreign-import/src/record.rs | 4 + .../foreign-import/src/review/mapping.rs | 8 + .../crates/foreign-import/src/review/notes.rs | 2 + core/crates/foreign-import/tests/beekeeper.rs | 43 ++ core/crates/foreign-import/tests/datagrip.rs | 38 +- core/crates/foreign-import/tests/dbeaver.rs | 36 ++ .../beekeeper/tls_off_connections.sql | 8 + .../options/dataSources.local.xml | 14 + .../DataGrip2026.2/options/dataSources.xml | 71 +++ .../General/.dbeaver/data-sources.json | 113 +++++ .../Sequel Ace/Data/Favorites.plist | 294 ++++++++++++ core/crates/foreign-import/tests/review.rs | 51 ++ .../crates/foreign-import/tests/sequel_ace.rs | 40 +- core/crates/foreign-import/tests/tableplus.rs | 10 + .../app/src/ui/import_from_app/wording.rs | 4 + 55 files changed, 2881 insertions(+), 401 deletions(-) create mode 100644 apple/TablePro/Core/Services/Export/ForeignApp/ForeignAppFile.swift create mode 100644 apple/changelog.d/4015.fixed.md create mode 100644 core/crates/foreign-import/tests/fixtures/beekeeper/tls_off_connections.sql create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/tls-defaults/linux/.config/JetBrains/DataGrip2026.2/options/dataSources.local.xml create mode 100644 core/crates/foreign-import/tests/fixtures/datagrip/tls-defaults/linux/.config/JetBrains/DataGrip2026.2/options/dataSources.xml create mode 100644 core/crates/foreign-import/tests/fixtures/dbeaver/linux-tls-defaults/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json create mode 100644 core/crates/foreign-import/tests/fixtures/sequel_ace/tls-off/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Import/CollectedImport.swift b/apple/Packages/TableProCore/Sources/TableProImport/Import/CollectedImport.swift index 899da031ee..9ad7025b86 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Import/CollectedImport.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Import/CollectedImport.swift @@ -36,6 +36,7 @@ public struct CollectedImport: Sendable { public let unsuggestedQueries: Set public let oversizedQueries: [OversizedSavedQuery] public let credentialsAborted: Bool + public let connectionNotes: [BundleRef: [String]] public init( bundle: ConnectionBundle, @@ -43,7 +44,8 @@ public struct CollectedImport: Sendable { unsuggestedConnections: Set = [], unsuggestedQueries: Set = [], oversizedQueries: [OversizedSavedQuery] = [], - credentialsAborted: Bool = false + credentialsAborted: Bool = false, + connectionNotes: [BundleRef: [String]] = [:] ) { self.bundle = bundle self.source = source @@ -51,5 +53,6 @@ public struct CollectedImport: Sendable { self.unsuggestedQueries = unsuggestedQueries self.oversizedQueries = oversizedQueries self.credentialsAborted = credentialsAborted + self.connectionNotes = connectionNotes } } diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Import/ConnectionImportAnalyzer.swift b/apple/Packages/TableProCore/Sources/TableProImport/Import/ConnectionImportAnalyzer.swift index 09ee5c48c1..bb6754e467 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Import/ConnectionImportAnalyzer.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Import/ConnectionImportAnalyzer.swift @@ -47,7 +47,8 @@ public enum ConnectionImportAnalyzer { groupPath: Array(groupPath), tagNames: connection.tagNames, duplicate: duplicate, - warnings: warnings(for: settings, canonicalTypeId: canonicalTypeId, environment: environment), + warnings: warnings(for: settings, canonicalTypeId: canonicalTypeId, environment: environment) + + (collected.connectionNotes[connection.ref] ?? []), unsupportedTypeId: canonicalTypeId == nil ? connection.settings.type : nil, savedQueryCount: savedQueryCount, resolutions: resolutions( diff --git a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift index e729043dfd..a492bf9894 100644 --- a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift @@ -378,6 +378,31 @@ struct ConnectionImportAnalyzerTests { #expect(row.isSelectedByDefault) } + @Test("A connection's notes follow its own warnings, on that row only") + func connectionNotesFollowWarnings() throws { + var settings = Fixtures.settings() + settings.sslConfig = ExportableSSLConfig(mode: "sometimes") + let collected = CollectedImport( + bundle: try Fixtures.makeBundle(connections: [ + BundleConnection(ref: "c1", settings: settings), + BundleConnection(ref: "c2", settings: Fixtures.settings(host: "other")) + ]), + source: .foreignApp(name: "Sample App"), + connectionNotes: ["c1": ["SSL is on by default"]] + ) + + let preview = ConnectionImportAnalyzer.analyze( + collected, + library: ImportLibrarySnapshot(), + environment: Fixtures.makeEnvironment() + ) + + #expect(preview.connections[0].warnings.count == 2) + #expect(preview.connections[0].warnings.last == "SSL is on by default") + #expect(preview.connections[0].isSelectedByDefault) + #expect(preview.connections[1].warnings.isEmpty) + } + @Test("An iOS SSL spelling is recognized and does not warn") func iOSSpellingDoesNotWarn() throws { var settings = Fixtures.settings() diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift b/apple/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift index 2a0b3b8808..99993d1d4f 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift @@ -32,8 +32,10 @@ struct BeekeeperStudioImporter: ForeignAppImporter { return ForeignAppInventory(connections: 0, savedQueries: 0) } defer { sqlite3_close(db) } + // Only the rows collect imports: one with no connection type is skipped there. + let rows = (try? readSavedConnections(db: db)) ?? [] return ForeignAppInventory( - connections: (try? readSavedConnections(db: db).count) ?? 0, + connections: rows.count { Self.mapDriver($0.connectionType) != nil }, savedQueries: BeekeeperSavedQueryReader.count(db: db) ) } @@ -60,6 +62,7 @@ struct BeekeeperStudioImporter: ForeignAppImporter { Self.logger.warning("Skipping a Beekeeper connection with no connection type") continue } + var notes: [String] = [] let settings = ExportableConnection( name: row.name.isEmpty ? "Untitled" : row.name, host: row.host.isEmpty ? "localhost" : row.host, @@ -68,14 +71,15 @@ struct BeekeeperStudioImporter: ForeignAppImporter { username: row.username ?? "", type: type, sshConfig: row.sshEnabled ? Self.buildSSHConfig(row) : nil, - sslConfig: row.ssl ? Self.buildSSLConfig(row) : nil, + sslConfig: buildSSLConfig(row, type: type, notes: ¬es), color: Self.mapColor(row.labelColor) ) records.append(ForeignConnectionRecord( sourceId: String(row.id), settings: settings, groupPath: row.connectionFolderId.flatMap { folderMap[$0] }.map { [$0] } ?? [], - credentials: userKey.flatMap { Self.extractCredentials(row: row, key: $0) } + credentials: userKey.flatMap { Self.extractCredentials(row: row, key: $0) }, + notes: notes )) } @@ -156,18 +160,40 @@ struct BeekeeperStudioImporter: ForeignAppImporter { // MARK: - SSL Mapping - private static func buildSSLConfig(_ row: SavedConnectionRow) -> ExportableSSLConfig { - let mode = row.sslRejectUnauthorized && !row.trustServerCertificate - ? "Verify Identity" - : "Required" + private func buildSSLConfig(_ row: SavedConnectionRow, type: String, notes: inout [String]) -> ExportableSSLConfig? { + guard row.ssl else { + // With `ssl` off mssql still hands tedious `encrypt: true`, and the saved `trustServerCertificate` + // decides the certificate check. The other connectors connect in the clear. + guard type == "SQL Server" else { return nil } + let mode: SSLMode = row.trustServerCertificate ? .required : .verifyIdentity + notes.append(ForeignImportNote.tlsFromDriverDefault(mode, appName: displayName)) + return ExportableSSLConfig(mode: mode.rawValue) + } return ExportableSSLConfig( - mode: mode, + mode: Self.verifiesServer(row, type: type) ? SSLMode.verifyIdentity.rawValue : SSLMode.required.rawValue, caCertificatePath: row.sslCaFile, clientCertificatePath: row.sslCertFile, clientKeyPath: row.sslKeyFile ) } + // What each Beekeeper connector hands its driver (`lib/db/clients/*/*Connector.ts`). The saved + // `trustServerCertificate` is SQL Server's alone, and TLS on replaces it there. + private static func verifiesServer(_ row: SavedConnectionRow, type: String) -> Bool { + let hasCA = !(row.sslCaFile ?? "").isEmpty + let hasCert = !(row.sslCertFile ?? "").isEmpty + let hasKey = !(row.sslKeyFile ?? "").isEmpty + switch type { + case "SQL Server": + return !(hasCA && hasCert && hasKey) || row.sslRejectUnauthorized + // With no certificate file these connectors never check the server. + case "PostgreSQL", "Redshift", "CockroachDB", "MySQL", "MariaDB", "TiDB": + return row.sslRejectUnauthorized && (hasCA || hasCert || hasKey) + default: + return row.sslRejectUnauthorized + } + } + // MARK: - Color private static func mapColor(_ raw: String?) -> String? { @@ -187,7 +213,7 @@ struct BeekeeperStudioImporter: ForeignAppImporter { // MARK: - Credentials private func loadUserEncryptionKey() -> String? { - guard let data = try? Data(contentsOf: keyFileURL), + guard let data = try? ForeignAppFile.read(keyFileURL), let payload = String(data: data, encoding: .utf8), let decoded = BeekeeperEncryptor.decryptDictionary(payload, key: BeekeeperEncryptor.defaultKey), let key = decoded["encryptionKey"] as? String else { diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift b/apple/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift index 154e3e3dd1..90f8d221e2 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift @@ -51,57 +51,60 @@ struct DBeaverImporter: ForeignAppImporter { } func isAvailable() -> Bool { - installedAppURL() != nil || findDataSourcesFile() != nil + installedAppURL() != nil || findProject() != nil } func inventory() -> ForeignAppInventory { - guard let url = findDataSourcesFile() else { + guard let project = findProject() else { return ForeignAppInventory(connections: 0, savedQueries: 0) } - let connections = loadJSON(from: url)?["connections"] as? [String: Any] + let connections = project.configs.reduce(0) { total, config in + total + ((loadJSON(from: config.url)?["connections"] as? [String: Any])?.count ?? 0) + } return ForeignAppInventory( - connections: connections?.count ?? 0, - savedQueries: DBeaverScriptReader.count(projectURL: Self.projectURL(for: url)) + connections: connections, + savedQueries: DBeaverScriptReader.count(projectURL: project.url) ) } func collect(_ request: ForeignImportRequest) throws -> CollectedImport { - guard let dataSourcesURL = findDataSourcesFile() else { + guard let project = findProject() else { throw ForeignAppImportError.fileNotFound(displayName) } - - guard let json = loadJSON(from: dataSourcesURL) else { - throw ForeignAppImportError.parseError("Could not parse data-sources.json") - } - - guard let connectionsDict = json["connections"] as? [String: [String: Any]] else { - throw ForeignAppImportError.unsupportedFormat("Missing connections key in data-sources.json") - } - - let foldersDict = json["folders"] as? [String: [String: Any]] ?? [:] - - let credentialsURL = dataSourcesURL.deletingLastPathComponent() - .appendingPathComponent("credentials-config.json") - let credentialsMap = loadCredentials(from: credentialsURL) + let sources = try readDataSources(project) + let folders = Self.folderDescriptions(sources) var records: [ForeignConnectionRecord] = [] - for (connId, connDict) in connectionsDict { - try Task.checkCancellation() - let credentialUsername = (credentialsMap[connId]?["#connection"] as? [String: Any])?["user"] as? String - let credentials = request.includePasswords ? credentialsMap[connId].flatMap { extractCredentials(from: $0) } : nil - records.append(ForeignConnectionRecord( - sourceId: connId, - settings: parseConnection(connId, dict: connDict, credentialUsername: credentialUsername), - groupPath: Self.groupPath(connDict["folder"] as? String, folders: foldersDict), - credentials: credentials - )) + for source in sources { + for (connId, connDict) in source.connections { + try Task.checkCancellation() + let entry = source.credentials[connId] + var notes: [String] = [] + let settings = parseConnection( + connId, + dict: connDict, + credentialUsername: (entry?["#connection"] as? [String: Any])?["user"] as? String, + tunnelUsername: entry.flatMap { Self.tunnelField($0, "user") }, + notes: ¬es + ) + let credentials = request.includePasswords + ? entry.flatMap { extractCredentials(from: $0, settings: settings) } + : nil + records.append(ForeignConnectionRecord( + sourceId: connId, + settings: settings, + groupPath: Self.groupPath(connDict["folder"] as? String, descriptions: folders), + credentials: credentials, + notes: notes + )) + } } // The JSON object has no order once parsed, so sort for a stable review list. records.sort { ($0.settings.name, $0.sourceId ?? "") < ($1.settings.name, $1.sourceId ?? "") } let savedQueries = request.includeSavedQueries ? try DBeaverScriptReader.savedQueries( - projectURL: Self.projectURL(for: dataSourcesURL), + projectURL: project.url, limit: SavedQuerySize.maximumSyncableByteCount ) : [] @@ -116,39 +119,96 @@ struct DBeaverImporter: ForeignAppImporter { // MARK: - File Discovery - private static func projectURL(for dataSourcesURL: URL) -> URL { - dataSourcesURL.deletingLastPathComponent().deletingLastPathComponent() + private struct Project { + let url: URL + let configs: [ConfigFile] + } + + // DBeaver pairs `data-sources.json` with `credentials-config.json`. + private struct ConfigFile { + let name: String + let url: URL + let credentialsURL: URL + let isDefault: Bool } - // Enumerates `workspace*` so a future `workspace7` keeps working. - private func findDataSourcesFile() -> URL? { + private struct DataSources { + let connections: [String: [String: Any]] + let folders: [String: [String: Any]] + let credentials: [String: [String: Any]] + } + + private static let configPrefix = "data-sources" + private static let credentialsPrefix = "credentials-config" + private static let jsonExtension = ".json" + + // Sorted, so the pick never depends on the order the file system lists folders in. + private func findProject() -> Project? { let fm = FileManager.default - guard let workspaceDirs = try? fm.contentsOfDirectory( - at: dbeaverDataRoot, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) else { return nil } - - let workspaces = workspaceDirs - .filter { $0.lastPathComponent.hasPrefix("workspace") } - .sorted { $0.lastPathComponent > $1.lastPathComponent } - - for workspace in workspaces { - guard let projects = try? fm.contentsOfDirectory(atPath: workspace.path) else { continue } - for projectName in projects { - let candidate = workspace - .appendingPathComponent(projectName) - .appendingPathComponent(".dbeaver/data-sources.json") - if fm.fileExists(atPath: candidate.path) { - return candidate + guard let workspaceNames = try? fm.contentsOfDirectory(atPath: dbeaverDataRoot.path) else { return nil } + for workspaceName in workspaceNames.filter({ $0.hasPrefix("workspace") }).sorted(by: >) { + let workspace = dbeaverDataRoot.appendingPathComponent(workspaceName, isDirectory: true) + guard let projectNames = try? fm.contentsOfDirectory(atPath: workspace.path) else { continue } + for projectName in projectNames.sorted() { + let projectURL = workspace.appendingPathComponent(projectName, isDirectory: true) + let configs = Self.configFiles(in: projectURL.appendingPathComponent(".dbeaver", isDirectory: true)) + if !configs.isEmpty { + return Project(url: projectURL, configs: configs) } } } return nil } + private static func configFiles(in metadataFolder: URL) -> [ConfigFile] { + guard let names = try? FileManager.default.contentsOfDirectory(atPath: metadataFolder.path) else { return [] } + let configs = names.compactMap { name -> ConfigFile? in + guard name.hasPrefix(configPrefix), name.hasSuffix(jsonExtension), + name.count >= configPrefix.count + jsonExtension.count else { return nil } + let url = metadataFolder.appendingPathComponent(name) + let values = try? url.resolvingSymlinksInPath().resourceValues(forKeys: [.isRegularFileKey]) + guard values?.isRegularFile == true else { return nil } + let subId = String(name.dropFirst(configPrefix.count).dropLast(jsonExtension.count)) + return ConfigFile( + name: name, + url: url, + credentialsURL: metadataFolder.appendingPathComponent(credentialsPrefix + subId + jsonExtension), + isDefault: subId.isEmpty + ) + } + return configs.sorted { ($0.isDefault ? 0 : 1, $0.name) < ($1.isDefault ? 0 : 1, $1.name) } + } + + private func readDataSources(_ project: Project) throws -> [DataSources] { + var sources: [DataSources] = [] + for config in project.configs { + do { + sources.append(try readConfig(config)) + } catch { + // A broken extra file leaves the main file's connections importable. + guard !config.isDefault else { throw error } + Self.logger.warning("Skipped DBeaver data sources file \(config.name, privacy: .public)") + } + } + return sources + } + + private func readConfig(_ config: ConfigFile) throws -> DataSources { + guard let json = loadJSON(from: config.url) else { + throw ForeignAppImportError.parseError("Could not parse \(config.name)") + } + guard let connections = json["connections"] as? [String: [String: Any]] else { + throw ForeignAppImportError.unsupportedFormat("Missing connections key in \(config.name)") + } + return DataSources( + connections: connections, + folders: json["folders"] as? [String: [String: Any]] ?? [:], + credentials: loadCredentials(from: config.credentialsURL) + ) + } + private func loadJSON(from url: URL) -> [String: Any]? { - guard let data = try? Data(contentsOf: url), + guard let data = try? ForeignAppFile.read(url), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return json } @@ -158,27 +218,45 @@ struct DBeaverImporter: ForeignAppImporter { private func parseConnection( _ connId: String, dict: [String: Any], - credentialUsername: String? + credentialUsername: String?, + tunnelUsername: String?, + notes: inout [String] ) -> ExportableConnection { let name = dict["name"] as? String ?? connId let provider = dict["provider"] as? String ?? "" let dbType = mapProvider(provider) let config = dict["configuration"] as? [String: Any] ?? [:] - let host = config["host"] as? String ?? "localhost" - let port: Int - if let intPort = config["port"] as? Int { - port = intPort - } else if let strPort = config["port"] as? String, let parsed = Int(strPort) { - port = parsed - } else { - port = ForeignAppDatabaseType.defaultPort(for: dbType) - } - let database = config["database"] as? String ?? config["url"] as? String ?? "" - let username = [credentialUsername, config["user"] as? String] + let url = (config["url"] as? String).flatMap { $0.isEmpty ? nil : $0 } + // In URL mode DBeaver connects with the URL as written, and the manual fields are leftovers. + let urlMode = config["configurationType"] as? String == "URL" + let hostIsEmpty = (config["host"] as? String)?.isEmpty ?? true + // Only what the URL names is kept, never the URL itself, which can hold a password. + let endpoint = (urlMode || hostIsEmpty) ? url.flatMap(Self.endpoint) : nil + let fromURL = urlMode && endpoint != nil + let manual: (String) -> Any? = { fromURL ? nil : config[$0] } + + let host = (manual("host") as? String).flatMap { $0.isEmpty ? nil : $0 } ?? endpoint?.host ?? "localhost" + let database = manual("database") as? String ?? endpoint?.database ?? "" + let port = Self.port(manual("port")) ?? endpoint?.port ?? ForeignAppDatabaseType.defaultPort(for: dbType) + let username = [credentialUsername, config["user"] as? String, endpoint?.username] .compactMap { $0 } .first { !$0.isEmpty } ?? "" + // Read whether or not the host came from the URL: a seed list and an auth database live only there. + let additionalFields = url.map(JDBCConnectionString.urlFields) ?? [:] + let sslConfig: ExportableSSLConfig? + switch Self.handlerTLS(config) { + case .on(let ssl): + sslConfig = ssl + case .unread(let mode): + sslConfig = ExportableSSLConfig(mode: mode) + case .off: + sslConfig = url.flatMap(Self.urlSSL) + case .absent: + sslConfig = url.flatMap(Self.urlSSL) ?? driverDefaultSSL(dict, configuration: config, url: url, notes: ¬es) + } + return ExportableConnection( name: name, host: host, @@ -186,26 +264,94 @@ struct DBeaverImporter: ForeignAppImporter { database: database, username: username, type: dbType, - sshConfig: parseSSHConfig(config), - sslConfig: parseSSLConfig(config), - color: parseColor(config) + sshConfig: parseSSHConfig(config, credentialUsername: tunnelUsername), + sslConfig: sslConfig, + color: parseColor(config), + additionalFields: additionalFields.isEmpty ? nil : additionalFields ) } + private static func endpoint(_ url: String) -> JDBCConnectionString.Endpoint? { + JDBCConnectionString.parse(url: url, subprotocol: JDBCConnectionString.subprotocol(of: url)) + } + + // A URL that sets TLS a way this cannot read keeps its own text as the mode, so the review warns. + private static func urlSSL(_ url: String) -> ExportableSSLConfig? { + switch JDBCConnectionString.tls(of: url) { + case .none, .mode(.disabled): + return nil + case .mode(let mode): + return ExportableSSLConfig(mode: mode.rawValue) + case .unread(let written): + return ExportableSSLConfig(mode: written) + } + } + + // The PostgreSQL drivers DBeaver ships as pgjdbc (`org.postgresql.Driver` in its plugin.xml). + private static let pgjdbcDrivers: Set = [ + "postgres-jdbc", + "postgresql", + "postgres-greenplum-jdbc", + "postgres-timescale-jdbc", + "postgres-yugabytedb-jdbc", + "postgres-cockroach-jdbc", + "postgres-materialize-jdbc", + "postgres-gcloud-jdbc", + "postgres-cratedb-jdbc", + "postgres-risingwave-jdbc" + ] + + // With no TLS handler DBeaver hands pgjdbc `ssl=false` and no `sslmode`, which pgjdbc reads as + // `prefer`. Its MySQL and SQL Server connections turn TLS off outright. + private func driverDefaultSSL( + _ connection: [String: Any], + configuration: [String: Any], + url: String?, + notes: inout [String] + ) -> ExportableSSLConfig? { + guard (connection["provider"] as? String)?.lowercased() == "postgresql", + let driver = connection["driver"] as? String, Self.pgjdbcDrivers.contains(driver) else { return nil } + if let url, JDBCConnectionString.tls(of: url) != JDBCConnectionString.TLS.none { + return nil + } + // A driver property that sets TLS is not read, so its name is the mode the review flags. + let properties = configuration["properties"] as? [String: Any] ?? [:] + let switches = properties.keys.filter { JDBCConnectionString.isTLSParameter($0) }.sorted() + guard switches.isEmpty else { return ExportableSSLConfig(mode: switches.joined(separator: ", ")) } + notes.append(ForeignImportNote.tlsFromDriverDefault(.preferred, appName: displayName)) + return ExportableSSLConfig(mode: SSLMode.preferred.rawValue) + } + + private static func port(_ value: Any?) -> Int? { + if let number = value as? Int { + return number + } + return (value as? String).flatMap { Int($0) } + } + + // A folder defined in one file can hold connections of another, as DBeaver keeps one folder tree. + private static func folderDescriptions(_ sources: [DataSources]) -> [String: String] { + var descriptions: [String: String] = [:] + for source in sources { + for (path, folder) in source.folders where descriptions[path] == nil { + if let description = folder["description"] as? String, !description.isEmpty { + descriptions[path] = description + } + } + } + return descriptions + } + // A folder key is its full path; a folder's description, when set, is the name DBeaver shows. - private static func groupPath(_ folder: String?, folders: [String: [String: Any]]) -> [String] { + private static func groupPath(_ folder: String?, descriptions: [String: String]) -> [String] { guard let folder, !folder.isEmpty else { return [] } let components = folder.split(separator: "/").map(String.init) return components.indices.map { index in - let key = components[...index].joined(separator: "/") - if let description = folders[key]?["description"] as? String, !description.isEmpty { - return description - } - return components[index] + descriptions[components[...index].joined(separator: "/")] ?? components[index] } } - private func parseSSHConfig(_ config: [String: Any]) -> ExportableSSHConfig? { + private func parseSSHConfig(_ config: [String: Any], credentialUsername: String?) -> ExportableSSHConfig? { guard let handlers = config["handlers"] as? [String: Any], let sshTunnel = handlers["ssh_tunnel"] as? [String: Any] else { return nil } @@ -215,15 +361,10 @@ struct DBeaverImporter: ForeignAppImporter { guard enabled else { return nil } let host = properties["host"] as? String ?? "" - let port: Int? - if let intPort = properties["port"] as? Int { - port = intPort - } else if let strPort = properties["port"] as? String, let parsed = Int(strPort) { - port = parsed - } else { - port = nil - } - let username = properties["username"] as? String ?? "" + // DBeaver keeps the tunnel's user with its secrets, or beside its properties in an older file. + let username = [properties["username"] as? String, sshTunnel["user"] as? String, credentialUsername] + .compactMap { $0 } + .first { !$0.isEmpty } ?? "" let authType = properties["authType"] as? String ?? "PASSWORD" let rawKeyPath = properties["keyPath"] as? String ?? "" let keyPath = ForeignAppPathHelper.resolveKeyPath(rawKeyPath) @@ -238,7 +379,7 @@ struct DBeaverImporter: ForeignAppImporter { return ExportableSSHConfig( enabled: true, host: host, - port: port, + port: Self.port(properties["port"]), username: username, authMethod: authMethod, privateKeyPath: authType == "PUBLIC_KEY" ? keyPath : "", @@ -251,32 +392,103 @@ struct DBeaverImporter: ForeignAppImporter { ) } - private func parseSSLConfig(_ config: [String: Any]) -> ExportableSSLConfig? { - guard let handlers = config["handlers"] as? [String: Any], - let sslHandler = handlers["ssl"] as? [String: Any] else { return nil } + // MARK: - TLS - let enabled = sslHandler["enabled"] as? Bool ?? false - guard enabled else { return nil } + private enum HandlerTLS { + case absent + case off + case on(ExportableSSLConfig) + case unread(String) + } - let properties = sslHandler["properties"] as? [String: Any] ?? [:] + // DBeaver names the TLS handler per engine: `postgre_ssl`, `mysql_ssl`, `mssql_ssl`, `clickhouse-ssl`. + private static func isTLSHandler(_ id: String) -> Bool { + id == "ssl" || id.hasSuffix("_ssl") || id.hasSuffix("-ssl") + } - let mode: String - switch properties["sslMode"] as? String ?? "" { - case "require": mode = SSLMode.required.rawValue - case "verify-ca": mode = SSLMode.verifyCa.rawValue - case "verify-full": mode = SSLMode.verifyIdentity.rawValue - default: mode = SSLMode.preferred.rawValue + private static func handlerTLS(_ config: [String: Any]) -> HandlerTLS { + let handlers = config["handlers"] as? [String: Any] ?? [:] + var outcome = HandlerTLS.absent + for id in handlers.keys.sorted() where isTLSHandler(id) { + guard let handler = handlers[id] as? [String: Any], handler["enabled"] as? Bool == true else { continue } + let properties = handler["properties"] as? [String: Any] ?? [:] + guard let mode = tlsMode(handler: id, properties: properties) else { + outcome = .unread(unreadMode(handler: id, properties: properties)) + continue + } + if mode != .disabled { + return .on(tlsSettings(mode, properties: properties)) + } + if case .absent = outcome { + outcome = .off + } } + return outcome + } - let caCertPath = properties["caCertPath"] as? String - let clientCertPath = properties["clientCertPath"] as? String - let clientKeyPath = properties["clientKeyPath"] as? String + // The mode each engine's handler hands its driver; nil when the handler keeps no mode this can read. + private static func tlsMode(handler id: String, properties: [String: Any]) -> SSLMode? { + func text(_ key: String) -> String? { + (properties[key] as? String).flatMap { $0.isEmpty ? nil : $0 } + } + func flag(_ key: String) -> Bool { + properties[key] as? Bool ?? ((properties[key] as? String)?.lowercased() == "true") + } + switch id { + case "postgre_ssl": + // The PostgreSQL driver takes `ssl=true` with no mode as verify-full. + guard let mode = text("sslMode") else { return .verifyIdentity } + return namedMode(mode) + case "mysql_ssl": + // Connector/J's legacy switches: checking the certificate is verify-ca. + if flag("ssl.verify.server") { + return .verifyCa + } + return flag("ssl.require") ? .required : .preferred + case "clickhouse-ssl": + // The ClickHouse driver checks the server unless the mode is NONE. + switch text("ssl.mode")?.uppercased() { + case nil, "STRICT": return .verifyIdentity + case "NONE": return .required + default: return nil + } + default: + return text("sslMode").flatMap(namedMode) + } + } + private static func namedMode(_ mode: String) -> SSLMode? { + switch mode.lowercased() { + case "disable": return .disabled + case "allow", "prefer": return .preferred + case "require": return .required + case "verify-ca": return .verifyCa + case "verify-full": return .verifyIdentity + default: return nil + } + } + + // The handler's own mode text, or its id when that text would read as a mode the review accepts. + private static func unreadMode(handler id: String, properties: [String: Any]) -> String { + let written = ["sslMode", "ssl.mode"].lazy + .compactMap { properties[$0] as? String } + .first { !$0.isEmpty } + if let written, PortableSSLMode(carrying: written) == nil { + return written + } + return id + } + + // DBeaver's own keys first, then the PostgreSQL handler's older ones, then TablePro's earlier guess. + private static func tlsSettings(_ mode: SSLMode, properties: [String: Any]) -> ExportableSSLConfig { + func path(_ keys: String...) -> String? { + keys.lazy.compactMap { properties[$0] as? String }.first { !$0.isEmpty } + } return ExportableSSLConfig( - mode: mode, - caCertificatePath: caCertPath, - clientCertificatePath: clientCertPath, - clientKeyPath: clientKeyPath + mode: mode.rawValue, + caCertificatePath: path("ssl.ca.cert", "rootCert", "caCertPath"), + clientCertificatePath: path("ssl.client.cert", "clientCert", "clientCertPath"), + clientKeyPath: path("ssl.client.key", "clientKey", "clientKeyPath") ) } @@ -303,7 +515,7 @@ struct DBeaverImporter: ForeignAppImporter { ] private func loadCredentials(from url: URL) -> [String: [String: Any]] { - guard let data = try? Data(contentsOf: url), + guard let data = try? ForeignAppFile.read(url), let decrypted = decryptCredentials(data) else { return [:] } guard let json = try? JSONSerialization.jsonObject(with: decrypted) as? [String: Any] else { return [:] } @@ -341,24 +553,31 @@ struct DBeaverImporter: ForeignAppImporter { return Data(decryptedBytes.prefix(decryptedLength)) } - private func extractCredentials(from connCreds: [String: Any]) -> ExportableCredentials? { + private func extractCredentials(from connCreds: [String: Any], settings: ExportableConnection) -> ExportableCredentials? { let connectionBlock = connCreds["#connection"] as? [String: Any] ?? [:] let password = connectionBlock["password"] as? String + let tunnelSecret = Self.tunnelField(connCreds, "password") + guard password != nil || tunnelSecret != nil else { return nil } - let sshBlock = connCreds["ssh_tunnel"] as? [String: Any] ?? [:] - let sshPassword = sshBlock["password"] as? String - guard password != nil || sshPassword != nil else { return nil } - + // The tunnel keeps one secret: the password for password sign-in, the key's passphrase for a key. + let keyTunnel = settings.sshConfig?.authMethod == "Private Key" return ExportableCredentials( password: password, - sshPassword: sshPassword, - keyPassphrase: nil, + sshPassword: keyTunnel ? nil : tunnelSecret, + keyPassphrase: keyTunnel ? tunnelSecret : nil, sslClientKeyPassphrase: nil, totpSecret: nil, pluginSecureFields: nil ) } + // DBeaver keys a network handler's secrets `network/`; the bare id is TablePro's earlier guess. + private static func tunnelField(_ connCreds: [String: Any], _ field: String) -> String? { + ["network/ssh_tunnel", "ssh_tunnel"].lazy + .compactMap { (connCreds[$0] as? [String: Any])?[field] as? String } + .first + } + // MARK: - Mapping private func mapProvider(_ provider: String) -> String { diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift b/apple/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift index 91d3e77f1b..ac40463628 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift @@ -14,12 +14,28 @@ struct DataGripDataSource { let groupName: String? let ssh: DataGripSSHReference? let ssl: DataGripSSLProperties? + let driverPropertyNames: [String] } -/// A single `` element. DataGrip splits one logical data source -/// across the shared `dataSources.xml` (driver, jdbc-url, group) and the -/// machine-local `dataSources.local.xml` (user name, ssh-properties, -/// ssl-properties). Fragments from both files merge by uuid before resolving. +// The local file can turn off a section the shared file turns on; a file without the section leaves it. +enum DataGripSection { + case absent + case disabled + case enabled(Value) + + var value: Value? { + if case .enabled(let value) = self { return value } + return nil + } + + var isAbsent: Bool { + if case .absent = self { return true } + return false + } +} + +// DataGrip splits one data source across the shared `dataSources.xml` (driver, URL, group) and the +// machine-local `dataSources.local.xml` (user name, SSH, SSL); fragments of both merge by uuid. struct DataGripDataSourceFragment { let uuid: String var name: String? @@ -27,8 +43,9 @@ struct DataGripDataSourceFragment { var jdbcURL: String? var username: String? var groupName: String? - var ssh: DataGripSSHReference? - var ssl: DataGripSSLProperties? + var driverPropertyNames: [String]? + var ssh: DataGripSection = .absent + var ssl: DataGripSection = .absent mutating func merge(_ other: DataGripDataSourceFragment) { name = other.name ?? name @@ -36,8 +53,11 @@ struct DataGripDataSourceFragment { jdbcURL = other.jdbcURL ?? jdbcURL username = other.username ?? username groupName = other.groupName ?? groupName - ssh = other.ssh ?? ssh - ssl = other.ssl ?? ssl + if let names = other.driverPropertyNames { + driverPropertyNames = (driverPropertyNames ?? []) + names + } + if !other.ssh.isAbsent { ssh = other.ssh } + if !other.ssl.isAbsent { ssl = other.ssl } } func resolved() -> DataGripDataSource? { @@ -50,14 +70,14 @@ struct DataGripDataSourceFragment { jdbcURL: jdbcURL, username: username ?? "", groupName: groupName, - ssh: ssh, - ssl: ssl + ssh: ssh.value, + ssl: ssl.value, + driverPropertyNames: driverPropertyNames ?? [] ) } } struct DataGripSSHReference { - let enabled: Bool let configId: String? let inlineHost: String? let inlinePort: Int? @@ -121,37 +141,37 @@ enum DataGripDataSourceParser { fragment.jdbcURL = element.childText("jdbc-url").flatMap { $0.isEmpty ? nil : $0 } fragment.username = element.childText("user-name").flatMap { $0.isEmpty ? nil : $0 } fragment.groupName = element.attr("group-name").flatMap { $0.isEmpty ? nil : $0 } + fragment.driverPropertyNames = element.elements(forName: "driver-properties").first.map { properties in + properties.elements(forName: "property").compactMap { $0.attr("name") } + } fragment.ssh = parseSSHReference(element) fragment.ssl = parseSSLProperties(element) return fragment } - private static func parseSSHReference(_ element: XMLElement) -> DataGripSSHReference? { - guard let ssh = element.elements(forName: "ssh-properties").first else { return nil } - - let enabled = (ssh.childText("enabled") ?? ssh.attr("enabled")) == "true" - guard enabled else { return nil } + private static func parseSSHReference(_ element: XMLElement) -> DataGripSection { + guard let ssh = element.elements(forName: "ssh-properties").first else { return .absent } + guard (ssh.childText("enabled") ?? ssh.attr("enabled")) == "true" else { return .disabled } let configId = ssh.childText("ssh-config-id") ?? ssh.attr("ssh-config-id") - return DataGripSSHReference( - enabled: true, + return .enabled(DataGripSSHReference( configId: configId.flatMap { $0.isEmpty ? nil : $0 }, inlineHost: ssh.attr("host"), inlinePort: ssh.attr("port").flatMap { Int($0) }, inlineUser: ssh.attr("user") ?? ssh.attr("username") - ) + )) } - private static func parseSSLProperties(_ element: XMLElement) -> DataGripSSLProperties? { - guard let ssl = element.elements(forName: "ssl-config").first, - ssl.childText("enabled") == "true" else { return nil } + private static func parseSSLProperties(_ element: XMLElement) -> DataGripSection { + guard let ssl = element.elements(forName: "ssl-config").first else { return .absent } + guard ssl.childText("enabled") == "true" else { return .disabled } - return DataGripSSLProperties( + return .enabled(DataGripSSLProperties( mode: ssl.childText("mode"), caCertPath: ssl.certPath("ca-cert"), clientCertPath: ssl.certPath("client-cert"), clientKeyPath: ssl.certPath("client-key") - ) + )) } } diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift b/apple/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift index 8a0bcc5fe0..d3e8d08708 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift @@ -4,18 +4,35 @@ // import Foundation +import TableProPluginKit enum JDBCConnectionString { struct Endpoint { let host: String let port: Int? let database: String + // The user a URL names before `@` or in its `user` parameter; its password never leaves the URL. + let username: String? } + // What a URL says about TLS. Only `sslmode`, a secure scheme and a seed list say it plainly; the + // drivers read every other switch their own way and in combination, so a URL that uses one is unread. + enum TLS: Equatable { + case none + case mode(SSLMode) + case unread(String) + } + + static let mongoUseSrvField = "mongoUseSrv" + static let mongoAuthSourceField = "mongoAuthSource" + static func parse(url: String, subprotocol: String) -> Endpoint? { let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.lowercased().hasPrefix("jdbc:") else { return nil } - let body = String(trimmed.dropFirst("jdbc:".count)) + // The MongoDB driver takes its own URL, which DataGrip stores with no `jdbc:` before it. + if isNativeMongo(trimmed) { + return parseAuthority(trimmed) + } + guard let body = strippingPrefix("jdbc:", from: trimmed) else { return nil } switch subprotocol.lowercased() { case "sqlserver", "jtds": @@ -31,121 +48,185 @@ enum JDBCConnectionString { } } + static func isNativeMongo(_ url: String) -> Bool { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + return strippingPrefix("mongodb://", from: trimmed) != nil || isMongoSeedList(trimmed) + } + + static func isMongoSeedList(_ url: String) -> Bool { + strippingPrefix("mongodb+srv://", from: url.trimmingCharacters(in: .whitespacesAndNewlines)) != nil + } + + // What a MongoDB URL says that the connection keeps as a driver field: a seed list, and the + // database it signs in against. + static func urlFields(_ url: String) -> [String: String] { + var fields: [String: String] = [:] + if isMongoSeedList(url) { + fields[mongoUseSrvField] = "true" + } + let isMongo = isNativeMongo(url) || subprotocol(of: url).lowercased() == "mongodb" + if isMongo, let source = queryPairs(url, separators: ["&"], semicolonStartsQuery: false) + .last(where: { $0.key.lowercased() == "authsource" }) + .map({ percentDecoded($0.value) }), !source.isEmpty { + fields[mongoAuthSourceField] = source + } + return fields + } + + static func subprotocol(of url: String) -> String { + guard let rest = strippingPrefix("jdbc:", from: url) else { return "" } + return String(rest.prefix { $0 != ":" && $0 != "/" }) + } + + static func tls(of url: String) -> TLS { + let parameters = queryPairs(url, separators: ["&", ";"]) + let switches = parameters.filter { isTLSParameter($0.key) } + // Unread TLS names the switches, never their values: `sslpassword` holds a secret. + if switches.contains(where: { $0.key.lowercased() != "sslmode" }) { + return .unread(switches.map { $0.key }.joined(separator: ", ")) + } + if let mode = switches.last { + guard let named = namedMode(mode.value.lowercased()) else { + return .unread(mode.value.isEmpty ? mode.key : mode.value) + } + return .mode(named) + } + return isMongoSeedList(url) || hasSecureScheme(url) ? .mode(.verifyIdentity) : .none + } + + static func isTLSParameter(_ name: String) -> Bool { + let key = name.lowercased() + return key.hasPrefix("ssl") || key.hasPrefix("tls") + || ["usessl", "requiressl", "encrypt", "trustservercertificate", "verifyservercertificate"].contains(key) + } + + // `jdbc:clickhouse:https://` and the like: a scheme that is TLS by itself, verified by default. + private static func hasSecureScheme(_ url: String) -> Bool { + guard let range = url.range(of: "://") else { return false } + let scheme = url[.. SSLMode? { + switch mode { + case "disable", "disabled": return .disabled + case "allow", "prefer", "preferred": return .preferred + // MariaDB's `trust` encrypts without checking the certificate. + case "require", "required", "trust": return .required + case "verify-ca", "verify_ca": return .verifyCa + case "verify-full", "verify_full", "verify_identity": return .verifyIdentity + default: return nil + } + } + // MARK: - Authority Form - /// jdbc:://[user[:pass]@]host[:port][/database][?params] - /// Covers MySQL, MariaDB, PostgreSQL, ClickHouse, MongoDB, Cassandra, Redis. + // jdbc:://[user[:pass]@]host[:port][/database][?params], and MongoDB's own URL. private static func parseAuthority(_ body: String) -> Endpoint? { guard let schemeRange = body.range(of: "://") else { return nil } - var remainder = String(body[schemeRange.upperBound...]) - - remainder = stripQuery(from: remainder) + let remainder = stripQuery(from: String(body[schemeRange.upperBound...])) let pathSplit = splitOnce(remainder, separator: "/") let authority = stripUserInfo(pathSplit.head) - let database = pathSplit.tail ?? "" let firstHost = authority.components(separatedBy: ",").first ?? authority let (host, port) = parseHostPort(firstHost) guard !host.isEmpty else { return nil } - return Endpoint(host: host, port: port, database: database) + + let userInfo = pathSplit.head.lastIndex(of: "@").map { String(pathSplit.head[..<$0]) } + let infoUser = userInfo.map { percentDecoded(splitOnce($0, separator: ":").head) } + let parameterUser = urlParameter(body, name: "user").map { percentDecoded($0) } + let username = [infoUser, parameterUser].compactMap { $0 }.first { !$0.isEmpty } + // A database name escapes a space or a slash, and the drivers take the name as written. + let database = percentDecoded(pathSplit.tail ?? "") + return Endpoint(host: host, port: port, database: database, username: username) } // MARK: - SQL Server Form - /// jdbc:sqlserver://host[\instance][:port][;prop=value;...] - /// jdbc:jtds:sqlserver://host:port/database + // jdbc:sqlserver://host[\instance][:port][;prop=value;...] and jdbc:jtds:sqlserver://host:port/database private static func parseSQLServer(_ body: String) -> Endpoint? { var remainder = body - if remainder.lowercased().hasPrefix("jtds:") { - remainder = String(remainder.dropFirst("jtds:".count)) + if let rest = strippingPrefix("jtds:", from: remainder) { + remainder = rest } - if remainder.lowercased().hasPrefix("sqlserver:") { - remainder = String(remainder.dropFirst("sqlserver:".count)) + if let rest = strippingPrefix("sqlserver:", from: remainder) { + remainder = rest } guard remainder.hasPrefix("//") else { return nil } remainder = String(remainder.dropFirst(2)) let semicolonSplit = splitOnce(remainder, separator: ";") let properties = parseSemicolonProperties(semicolonSplit.tail ?? "") - - let beforeProps = semicolonSplit.head - let slashSplit = splitOnce(beforeProps, separator: "/") - var authority = slashSplit.head + let slashSplit = splitOnce(semicolonSplit.head, separator: "/") var database = slashSplit.tail ?? "" - if let backslash = authority.firstIndex(of: "\\") { - authority = String(authority[.. Endpoint? { guard let atIndex = body.firstIndex(of: "@") else { return nil } var descriptor = String(body[body.index(after: atIndex)...]) descriptor = stripQuery(from: descriptor) if descriptor.hasPrefix("//") { - descriptor = String(descriptor.dropFirst(2)) - let slashSplit = splitOnce(descriptor, separator: "/") - let (host, port) = parseHostPort(slashSplit.head) - guard !host.isEmpty else { return nil } - return Endpoint(host: host, port: port, database: slashSplit.tail ?? "") + return hostPortPath(String(descriptor.dropFirst(2))) } let parts = descriptor.components(separatedBy: ":") guard parts.count >= 2, !parts[0].isEmpty else { - let slashSplit = splitOnce(descriptor, separator: "/") - let (host, port) = parseHostPort(slashSplit.head) - guard !host.isEmpty else { return nil } - return Endpoint(host: host, port: port, database: slashSplit.tail ?? "") + return hostPortPath(descriptor) } - let host = parts[0] - let portAndRest = parts[1] - let slashSplit = splitOnce(portAndRest, separator: "/") + let slashSplit = splitOnce(parts[1], separator: "/") let port = Int(slashSplit.head) + let database = slashSplit.tail ?? (parts.count >= 3 ? parts[2] : "") + return Endpoint(host: parts[0], port: port, database: database, username: nil) + } - if let serviceName = slashSplit.tail { - return Endpoint(host: host, port: port, database: serviceName) - } - let sid = parts.count >= 3 ? parts[2] : "" - return Endpoint(host: host, port: port, database: sid) + private static func hostPortPath(_ descriptor: String) -> Endpoint? { + let slashSplit = splitOnce(descriptor, separator: "/") + let (host, port) = parseHostPort(slashSplit.head) + guard !host.isEmpty else { return nil } + return Endpoint(host: host, port: port, database: slashSplit.tail ?? "", username: nil) } // MARK: - File Form - /// jdbc:sqlite:/path/to/file.db, jdbc:duckdb:/path/to/file.duckdb + // jdbc:sqlite:/path/to/file.db, jdbc:duckdb:/path/to/file.duckdb private static func parseFilePath(_ body: String, subprotocol: String) -> Endpoint? { - guard body.lowercased().hasPrefix(subprotocol.lowercased() + ":") else { - return Endpoint(host: "", port: nil, database: stripQuery(from: body)) - } - var path = String(body.dropFirst(subprotocol.count + 1)) - path = stripQuery(from: path) - return Endpoint(host: "", port: nil, database: path) + let path = strippingPrefix(subprotocol + ":", from: body) ?? body + return Endpoint(host: "", port: nil, database: stripQuery(from: path), username: nil) } // MARK: - BigQuery Form - /// jdbc:bigquery://https://www.googleapis.com/bigquery/v2;ProjectId=my-project;... + // jdbc:bigquery://https://www.googleapis.com/bigquery/v2;ProjectId=my-project;... private static func parseBigQuery(_ body: String) -> Endpoint? { - guard body.hasPrefix("//") else { return nil } - let remainder = String(body.dropFirst(2)) - let semicolonSplit = splitOnce(remainder, separator: ";") + let remainder = strippingPrefix("bigquery:", from: body) ?? body + guard remainder.hasPrefix("//") else { return nil } + let semicolonSplit = splitOnce(String(remainder.dropFirst(2)), separator: ";") let properties = parseSemicolonProperties(semicolonSplit.tail ?? "") let project = properties["projectid"] ?? properties["project"] ?? "" - return Endpoint(host: semicolonSplit.head, port: nil, database: project) + return Endpoint(host: semicolonSplit.head, port: nil, database: project, username: nil) } // MARK: - Helpers @@ -197,4 +278,61 @@ enum JDBCConnectionString { } return result } + + // The pairs after `?`, or after SQL Server's first `;`, trimmed and in their own case. + private static func queryPairs( + _ url: String, + separators: Set, + semicolonStartsQuery: Bool = true + ) -> [(key: String, value: String)] { + let semicolon = semicolonStartsQuery ? url.firstIndex(of: ";") : nil + guard let start = url.firstIndex(of: "?") ?? semicolon else { return [] } + return url[url.index(after: start)...] + .split(whereSeparator: { separators.contains($0) }) + .compactMap { pair in + guard let equals = pair.firstIndex(of: "=") else { return nil } + return ( + key: pair[.. String? { + queryPairs(url, separators: ["&", ";"]).last { $0.key.lowercased() == name.lowercased() }?.value + } + + // A user name in a URL escapes `@`, `:` and the like as `%40`; a malformed escape stays as written. + static func percentDecoded(_ text: String) -> String { + let bytes = Array(text.utf8) + var decoded: [UInt8] = [] + decoded.reserveCapacity(bytes.count) + var index = 0 + while index < bytes.count { + if bytes[index] == UInt8(ascii: "%"), index + 2 < bytes.count, + let high = hexValue(bytes[index + 1]), let low = hexValue(bytes[index + 2]) { + decoded.append(high << 4 | low) + index += 3 + } else { + decoded.append(bytes[index]) + index += 1 + } + } + // Escapes that decode to invalid UTF-8 leave the text as written. + return String(bytes: decoded, encoding: .utf8) ?? text + } + + private static func hexValue(_ byte: UInt8) -> UInt8? { + switch byte { + case UInt8(ascii: "0")...UInt8(ascii: "9"): return byte - UInt8(ascii: "0") + case UInt8(ascii: "a")...UInt8(ascii: "f"): return byte - UInt8(ascii: "a") + 10 + case UInt8(ascii: "A")...UInt8(ascii: "F"): return byte - UInt8(ascii: "A") + 10 + default: return nil + } + } + + private static func strippingPrefix(_ prefix: String, from value: String) -> String? { + guard value.prefix(prefix.count).lowercased() == prefix.lowercased() else { return nil } + return String(value.dropFirst(prefix.count)) + } } diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift b/apple/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift index bf98a028dc..46287c2aba 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift @@ -79,11 +79,14 @@ struct DataGripImporter: ForeignAppImporter { credentialsAborted = collected.aborted } + var notes: [String] = [] + let settings = makeConnection(scanned.source, sshConfigs: sshConfigs, notes: ¬es) records.append(ForeignConnectionRecord( sourceId: scanned.source.uuid, - settings: makeConnection(scanned.source, sshConfigs: sshConfigs), + settings: settings, groupPath: scanned.source.groupName.map { [$0] } ?? [], - credentials: credentials + credentials: credentials, + notes: notes )) } @@ -139,7 +142,8 @@ struct DataGripImporter: ForeignAppImporter { private func isMongo(driverRef: String?, jdbcURL: String?) -> Bool { let url = jdbcURL ?? "" if url.lowercased().hasPrefix("mongodb") { return true } - return mapDriverRef(driverRef ?? "", subprotocol: jdbcSubprotocol(url)) == DatabaseType.mongodb.rawValue + return mapDriverRef(driverRef ?? "", subprotocol: JDBCConnectionString.subprotocol(of: url)) + == DatabaseType.mongodb.rawValue } // MARK: - Credentials @@ -248,7 +252,7 @@ struct DataGripImporter: ForeignAppImporter { private func recentProjectPaths(configDir: URL) -> [String] { let url = configDir.appendingPathComponent("options/recentProjects.xml") - guard let data = try? Data(contentsOf: url), + guard let data = try? ForeignAppFile.read(url), let document = try? XMLDocument(data: data, options: [.nodeLoadExternalEntitiesNever]), let nodes = try? document.nodes(forXPath: "//entry/@key") else { return [] } @@ -259,18 +263,18 @@ struct DataGripImporter: ForeignAppImporter { private func loadSSHConfigs(configDir: URL) -> [String: DataGripSSHConfig] { let url = configDir.appendingPathComponent("options/sshConfigs.xml") - guard let data = try? Data(contentsOf: url) else { return [:] } + guard let data = try? ForeignAppFile.read(url) else { return [:] } return DataGripDataSourceParser.parseSSHConfigs(data) } - // The shared `dataSources.xml` carries the driver and JDBC URL, the machine-local - // `dataSources.local.xml` the user name, SSH and SSL; the local file wins per field. + // The shared file carries the driver and JDBC URL, the local file the user name, SSH and SSL; per field, + // and per SSH or SSL section it names, the local file wins. private func fragments(at location: Location) -> [DataGripDataSourceFragment] { var fragments: [String: DataGripDataSourceFragment] = [:] var order: [String] = [] for url in [location.dataSourcesURL, location.localURL].compactMap({ $0 }) { - guard let data = try? Data(contentsOf: url) else { continue } + guard let data = try? ForeignAppFile.read(url) else { continue } for fragment in DataGripDataSourceParser.parseFragments(data) { if fragments[fragment.uuid] == nil { order.append(fragment.uuid) @@ -287,25 +291,28 @@ struct DataGripImporter: ForeignAppImporter { private func makeConnection( _ source: DataGripDataSource, - sshConfigs: [String: DataGripSSHConfig] + sshConfigs: [String: DataGripSSHConfig], + notes: inout [String] ) -> ExportableConnection { - let subprotocol = jdbcSubprotocol(source.jdbcURL) + let subprotocol = JDBCConnectionString.subprotocol(of: source.jdbcURL) let type = mapDriverRef(source.driverRef, subprotocol: subprotocol) let endpoint = JDBCConnectionString.parse(url: source.jdbcURL, subprotocol: subprotocol) let host = endpoint?.host ?? "localhost" let database = endpoint?.database ?? "" let port = endpoint?.port ?? ForeignAppDatabaseType.defaultPort(for: type) + let fields = JDBCConnectionString.urlFields(source.jdbcURL) return ExportableConnection( name: source.name, host: host, port: port, database: database, - username: source.username, + username: source.username.isEmpty ? endpoint?.username ?? "" : source.username, type: type, sshConfig: makeSSHConfig(source.ssh, sshConfigs: sshConfigs), - sslConfig: makeSSLConfig(source.ssl) + sslConfig: makeSSLConfig(source, type: type, subprotocol: subprotocol, notes: ¬es), + additionalFields: fields.isEmpty ? nil : fields ) } @@ -313,7 +320,7 @@ struct DataGripImporter: ForeignAppImporter { _ reference: DataGripSSHReference?, sshConfigs: [String: DataGripSSHConfig] ) -> ExportableSSHConfig? { - guard let reference, reference.enabled else { return nil } + guard let reference else { return nil } let config = reference.configId.flatMap { sshConfigs[$0] } let host = config?.host ?? reference.inlineHost ?? "" @@ -338,8 +345,7 @@ struct DataGripImporter: ForeignAppImporter { ) } - /// DataGrip omits `authType` when the connection relies on the OpenSSH - /// config, so a present key path is the reliable signal for key auth. + // DataGrip omits `authType` when the connection relies on the OpenSSH config, so a key path is the signal. private func usesKeyAuthentication(authType: String?, keyPath: String) -> Bool { switch (authType ?? "").uppercased() { case "KEY_PAIR", "PUBLIC_KEY", "OPEN_SSH": @@ -351,9 +357,7 @@ struct DataGripImporter: ForeignAppImporter { } } - private func makeSSLConfig(_ ssl: DataGripSSLProperties?) -> ExportableSSLConfig? { - guard let ssl else { return nil } - + private func makeSSLConfig(_ ssl: DataGripSSLProperties) -> ExportableSSLConfig { let mode: String switch (ssl.mode ?? "").lowercased() { case "require", "required": mode = SSLMode.required.rawValue @@ -370,14 +374,42 @@ struct DataGripImporter: ForeignAppImporter { ) } - private func jdbcSubprotocol(_ url: String) -> String { - guard url.lowercased().hasPrefix("jdbc:") else { return "" } - var subprotocol = "" - for character in url.dropFirst("jdbc:".count) { - if character == ":" || character == "/" { break } - subprotocol.append(character) + // Without SSL settings the URL, then the driver's own default, decides. TLS set in a way this cannot read + // keeps the switch names as the mode, which the import flags and connects with SSL required. + private func makeSSLConfig( + _ source: DataGripDataSource, + type: String, + subprotocol: String, + notes: inout [String] + ) -> ExportableSSLConfig? { + if let ssl = source.ssl { + return makeSSLConfig(ssl) + } + let propertySwitches = source.driverPropertyNames.filter { JDBCConnectionString.isTLSParameter($0) } + guard propertySwitches.isEmpty else { + return ExportableSSLConfig(mode: propertySwitches.joined(separator: ", ")) + } + switch JDBCConnectionString.tls(of: source.jdbcURL) { + case .mode(.disabled): return nil + case .mode(let mode): return ExportableSSLConfig(mode: mode.rawValue) + case .unread(let written): return ExportableSSLConfig(mode: written) + case .none: + guard let mode = driverDefaultSSLMode(type: type, subprotocol: subprotocol) else { return nil } + notes.append(ForeignImportNote.tlsFromDriverDefault(mode, appName: displayName)) + return ExportableSSLConfig(mode: mode.rawValue) + } + } + + // DataGrip passes no TLS setting when "Use SSL" is off, so the driver's default applies: pgjdbc and + // Connector/J prefer TLS without checking the certificate, mssql-jdbc encrypts and checks it. MariaDB + // Connector/J and jTDS connect in the clear. + private func driverDefaultSSLMode(type: String, subprotocol: String) -> SSLMode? { + switch subprotocol.lowercased() { + case "postgresql": return .preferred + case "mysql": return type == "MySQL" ? .preferred : nil + case "sqlserver": return .verifyIdentity + default: return nil } - return subprotocol } private func mapDriverRef(_ driverRef: String, subprotocol: String) -> String { diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/ForeignAppFile.swift b/apple/TablePro/Core/Services/Export/ForeignApp/ForeignAppFile.swift new file mode 100644 index 0000000000..301a437c94 --- /dev/null +++ b/apple/TablePro/Core/Services/Export/ForeignApp/ForeignAppFile.swift @@ -0,0 +1,38 @@ +// +// ForeignAppFile.swift +// TablePro +// + +import Foundation + +enum ForeignAppFile { + enum ReadError: Error { + case notRegularFile + case tooLarge + } + + static let maximumByteCount = 16 * 1_024 * 1_024 + + // A path another app's settings name can be crafted, such as a project's `.idea` folder linked to + // `/dev/zero` or a pipe. O_NONBLOCK keeps the open of a pipe from waiting for a writer. + static func read(_ url: URL) throws -> Data { + let descriptor = open(url.path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + var info = stat() + guard fstat(descriptor, &info) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard info.st_mode & S_IFMT == S_IFREG else { throw ReadError.notRegularFile } + guard info.st_size <= maximumByteCount else { throw ReadError.tooLarge } + let data = try handle.read(upToCount: maximumByteCount + 1) ?? Data() + guard data.count <= maximumByteCount else { throw ReadError.tooLarge } + return data + } + + static func isMissing(_ error: any Error) -> Bool { + (error as? POSIXError)?.code == .ENOENT + } +} diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift b/apple/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift index 4e6a0ea017..22727f61ee 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift @@ -77,6 +77,18 @@ struct ForeignConnectionRecord: Sendable { let settings: ExportableConnection let groupPath: [String] let credentials: ExportableCredentials? + // Shown on the review row only: foreign imports skip sanitizedForImport, so a field would be saved. + var notes: [String] = [] +} + +enum ForeignImportNote { + static func tlsFromDriverDefault(_ mode: SSLMode, appName: String) -> String { + String( + format: String(localized: "SSL %1$@ is on because %2$@ encrypts this connection by default"), + mode.displayLabel, + appName + ) + } } struct ForeignSavedQuery: Sendable, Equatable { diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/ForeignBundleAssembly.swift b/apple/TablePro/Core/Services/Export/ForeignApp/ForeignBundleAssembly.swift index efc2d3ae76..4e6521bff3 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/ForeignBundleAssembly.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/ForeignBundleAssembly.swift @@ -20,6 +20,7 @@ enum ForeignBundleAssembly { var builder = ConnectionBundleBuilder(appVersion: "\(appName) Import") var refsBySourceId: [String: BundleRef] = [:] var usedRefs: Set = [] + var connectionNotes: [BundleRef: [String]] = [:] for (index, record) in connections.enumerated() { let sourceId = record.sourceId.flatMap { $0.isEmpty ? nil : $0 } @@ -27,6 +28,9 @@ enum ForeignBundleAssembly { if let sourceId, refsBySourceId[sourceId] == nil { refsBySourceId[sourceId] = ref } + if !record.notes.isEmpty { + connectionNotes[ref] = record.notes + } builder.addConnection( record.settings, ref: ref, @@ -74,7 +78,8 @@ enum ForeignBundleAssembly { source: .foreignApp(name: appName), unsuggestedQueries: unsuggestedQueries, oversizedQueries: oversizedQueries, - credentialsAborted: credentialsAborted + credentialsAborted: credentialsAborted, + connectionNotes: connectionNotes ) } diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift b/apple/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift index 3e48d06216..0d974e0bd7 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift @@ -8,16 +8,7 @@ import Foundation import os import Security -/// Resolves JetBrains credentials. The IDE names each secret with -/// `generateServiceName(subsystem, key)`, formatted -/// `IntelliJ Platform \u{2014} `: -/// - DB password: subsystem `DB`, key ``. -/// - SSH tunnel password: subsystem `SshConfigPassword`, key `: `. -/// - SSH key passphrase: subsystem `SshConfigPassphrase`, same key shape. -/// -/// SSH secrets only exist once a connection authenticated and saved them. On macOS -/// the secret lives in the native Keychain; only the "In KeePass" mode writes the -/// encrypted `c.kdbx`, so the Keychain is tried first and the KDBX is a fallback. +// The Keychain holds the IDE's secrets by default; only the "In KeePass" setting writes `c.kdbx`. final class JetBrainsCredentialStore { enum Lookup { case found(String) @@ -27,8 +18,6 @@ final class JetBrainsCredentialStore { private static let logger = Logger(subsystem: "com.TablePro", category: "JetBrainsCredentialStore") - /// ASCII bytes of "Proxy Config Sec", the hardcoded AES-128 key the IDE uses - /// for the BUILT_IN encryption of the KDBX main key in `c.pwd`. private static let builtInKey: [UInt8] = Array("Proxy Config Sec".utf8) private let configDir: URL @@ -111,7 +100,7 @@ final class JetBrainsCredentialStore { let kdbxURL = configDir.appendingPathComponent("c.kdbx") guard FileManager.default.fileExists(atPath: kdbxURL.path), - let fileData = try? Data(contentsOf: kdbxURL), + let fileData = try? ForeignAppFile.read(kdbxURL), let mainKey = loadMainKey() else { return nil } do { @@ -124,20 +113,28 @@ final class JetBrainsCredentialStore { } private func loadMainKey() -> [UInt8]? { - for fileName in ["c.pwd", "pdb.pwd"] { - let url = configDir.appendingPathComponent(fileName) - guard let text = try? String(contentsOf: url, encoding: .utf8), - let parsed = parseMainKeyFile(text) else { continue } - guard parsed.encryption == "BUILT_IN" else { - Self.logger.warning("Unsupported c.pwd encryption: \(parsed.encryption)") - storeLocked = true - continue - } - if let key = decryptBuiltIn(parsed.value) { - return key - } + let encryption: String + let blob: [UInt8] + do { + let data = try ForeignAppFile.read(configDir.appendingPathComponent("c.pwd")) + guard let text = String(data: data, encoding: .utf8), + let parsed = parseMainKeyFile(text) else { return nil } + encryption = parsed.encryption + blob = parsed.value + } catch let error where ForeignAppFile.isMissing(error) { + // The IDE reads the older `pdb.pwd`, raw bytes rather than YAML, only when `c.pwd` is missing. + guard let data = try? ForeignAppFile.read(configDir.appendingPathComponent("pdb.pwd")) else { return nil } + encryption = "BUILT_IN" + blob = [UInt8](data) + } catch { + return nil + } + guard encryption == "BUILT_IN" else { + Self.logger.warning("Unsupported c.pwd encryption: \(encryption)") + storeLocked = true + return nil } - return nil + return decryptBuiltIn(blob) } private func parseMainKeyFile(_ text: String) -> (encryption: String, value: [UInt8])? { diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift b/apple/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift index 70d1f2729c..00b4399a77 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift @@ -21,15 +21,18 @@ struct KdbxEntry { let password: String } -/// Reader for the KDBX 3.1 file (`c.kdbx`) that JetBrains IDEs write with their -/// own `com.intellij.credentialStore` implementation. Decryption is AES-KDF -/// (iterated AES-256-ECB) plus AES-256-CBC, so CommonCrypto is sufficient. +// The KDBX 3.1 file (`c.kdbx`) JetBrains IDEs write: AES-KDF, an AES-256-CBC body, optionally gzipped XML. enum KdbxDatabase { private static let logger = Logger(subsystem: "com.TablePro", category: "KdbxDatabase") private static let sig1: UInt32 = 0x9AA2_D903 private static let sig2: UInt32 = 0xB54B_FB67 + // Far above what the IDE writes, and a second or two of work at most, so a crafted file cannot + // hold the import for good. + static let maximumTransformRounds: UInt64 = 50_000_000 + static let maximumBodyByteCount = ForeignAppFile.maximumByteCount + private struct Header { var mainSeed: [UInt8] = [] var transformSeed: [UInt8] = [] @@ -70,17 +73,18 @@ enum KdbxDatabase { private static func parseHeader(_ data: [UInt8]) throws -> (Header, Int) { guard data.count > 12, readUInt32LE(data, 0) == sig1, - readUInt32LE(data, 4) == sig2 else { + readUInt32LE(data, 4) == sig2, + let version = readUInt32LE(data, 8) else { throw KdbxError.malformedHeader } - let version = readUInt32LE(data, 8) guard (version & 0xFFFF_0000) <= 0x0003_0000 else { throw KdbxError.unsupportedVersion } var header = Header() var pos = 12 while pos + 3 <= data.count { let fieldType = data[pos] - let length = Int(readUInt16LE(data, pos + 1)) + guard let rawLength = readUInt16LE(data, pos + 1) else { throw KdbxError.malformedHeader } + let length = Int(rawLength) pos += 3 guard pos + length <= data.count else { throw KdbxError.malformedHeader } let field = Array(data[pos..(_ value: Value?) throws -> Value { + guard let value else { throw KdbxError.malformedHeader } + return value + } + // MARK: - Key Derivation private static func deriveFinalKey(header: Header, mainKey: [UInt8]) throws -> [UInt8] { + guard header.transformRounds <= maximumTransformRounds else { throw KdbxError.malformedHeader } let composite = sha256(sha256(mainKey)) guard let transformed = aesKdf( input: composite, @@ -150,7 +161,8 @@ enum KdbxDatabase { var pos = 0 while pos + 40 <= data.count { let hashStart = pos + 4 - let size = Int(readUInt32LE(data, pos + 36)) + guard let rawSize = readUInt32LE(data, pos + 36) else { return nil } + let size = Int(rawSize) pos += 40 if size == 0 { return result } guard pos + size <= data.count else { return nil } @@ -186,7 +198,7 @@ enum KdbxDatabase { if var activeCipher = cipher, let valueNodes = try? document.nodes(forXPath: "//Value") { for node in valueNodes { guard let element = node as? XMLElement, - (element.attribute(forName: "Protected")?.stringValue ?? "").lowercased() == "true", + isProtected(element), let encoded = element.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), let data = Data(base64Encoded: encoded) else { continue } let plain = activeCipher.process([UInt8](data)) @@ -214,13 +226,24 @@ enum KdbxDatabase { case "Title": title = valueElement.stringValue ?? "" case "UserName": userName = valueElement.stringValue ?? "" case "Password": - password = decryptedValues[ObjectIdentifier(valueElement)] ?? (valueElement.stringValue ?? "") + if let decrypted = decryptedValues[ObjectIdentifier(valueElement)] { + password = decrypted + } else if !isProtected(valueElement) { + password = valueElement.stringValue ?? "" + } else { + // A protected value that did not decrypt is ciphertext, never a password. + password = "" + } default: break } } return KdbxEntry(title: title, userName: userName, password: password) } + private static func isProtected(_ value: XMLElement) -> Bool { + (value.attribute(forName: "Protected")?.stringValue ?? "").lowercased() == "true" + } + // MARK: - Crypto Primitives private static func aesCBCDecrypt(_ ciphertext: [UInt8], key: [UInt8], iv: [UInt8]) -> [UInt8]? { @@ -258,27 +281,39 @@ enum KdbxDatabase { // MARK: - GZIP + private struct BodyTooLarge: Error {} + private static func gunzip(_ data: [UInt8]) -> [UInt8]? { - guard let inflated = try? GzipInflater.decompress(Data(data)) else { return nil } - return [UInt8](inflated) + var inflated: [UInt8] = [] + do { + try GzipInflater.decompress(Data(data)) { chunk in + guard inflated.count + chunk.count <= maximumBodyByteCount else { throw BodyTooLarge() } + inflated.append(contentsOf: chunk) + } + } catch { + return nil + } + return inflated } // MARK: - Little-Endian Readers - private static func readUInt16LE(_ data: [UInt8], _ index: Int) -> UInt16 { - UInt16(data[index]) | (UInt16(data[index + 1]) << 8) + private static func readUInt16LE(_ data: [UInt8], _ index: Int) -> UInt16? { + readLittleEndian(data, index, byteCount: 2).map { UInt16($0) } + } + + private static func readUInt32LE(_ data: [UInt8], _ index: Int) -> UInt32? { + readLittleEndian(data, index, byteCount: 4).map { UInt32($0) } } - private static func readUInt32LE(_ data: [UInt8], _ index: Int) -> UInt32 { - UInt32(data[index]) - | (UInt32(data[index + 1]) << 8) - | (UInt32(data[index + 2]) << 16) - | (UInt32(data[index + 3]) << 24) + private static func readUInt64LE(_ data: [UInt8], _ index: Int) -> UInt64? { + readLittleEndian(data, index, byteCount: 8) } - private static func readUInt64LE(_ data: [UInt8], _ index: Int) -> UInt64 { + private static func readLittleEndian(_ data: [UInt8], _ index: Int, byteCount: Int) -> UInt64? { + guard index >= 0, index + byteCount <= data.count else { return nil } var value: UInt64 = 0 - for offset in 0..<8 { + for offset in 0.. String { + let lowered = name.lowercased() + return element.attributes?.first { $0.name?.lowercased() == lowered }?.stringValue ?? "" + } + func nonEmpty(_ value: String) -> String? { value.isEmpty ? nil : value } diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/BeekeeperSavedQueryReader.swift b/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/BeekeeperSavedQueryReader.swift index f0122bbe28..11b3b29796 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/BeekeeperSavedQueryReader.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/BeekeeperSavedQueryReader.swift @@ -12,6 +12,9 @@ enum BeekeeperSavedQueryReader { private static let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + // The blanks `count` trims in SQL, so the two agree on what a blank query is. + private static let countedBlanks = CharacterSet(charactersIn: " \t\n\r") + private struct Folder { let name: String let parentId: Int? @@ -45,7 +48,7 @@ enum BeekeeperSavedQueryReader { let content: ForeignSavedQuery.Content if byteCount > limit { content = .oversized(byteCount: byteCount) - } else if let sql = text(statement, 1), !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + } else if let sql = text(statement, 1), !sql.trimmingCharacters(in: countedBlanks).isEmpty { content = .text(sql) } else { continue diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/DataGripConsoleReader.swift b/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/DataGripConsoleReader.swift index 06bad774a7..2839d3f4ba 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/DataGripConsoleReader.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/DataGripConsoleReader.swift @@ -65,7 +65,7 @@ enum DataGripConsoleReader { static func baseName(configDir: URL) -> String { let url = configDir.appendingPathComponent("options/QueryFileSettings.xml") - guard let data = try? Data(contentsOf: url), + guard let data = try? ForeignAppFile.read(url), let document = try? XMLDocument(data: data, options: [.nodeLoadExternalEntitiesNever]), let node = try? document.nodes(forXPath: "//option[@name='scratchesName']/@value").first, let value = node.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/TablePlusFavoriteReader.swift b/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/TablePlusFavoriteReader.swift index 07eea7ab19..c504438f3c 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/TablePlusFavoriteReader.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/SavedQueries/TablePlusFavoriteReader.swift @@ -66,7 +66,10 @@ enum TablePlusFavoriteReader { static func keyword(for entry: ForeignSQLFileTree.Entry) -> String? { let tagURL = entry.url.deletingLastPathComponent() .appendingPathComponent(entry.fileName + keywordFileSuffix) - guard let handle = try? FileHandle(forReadingFrom: tagURL) else { return nil } + // A link or a pipe beside a favorite is never opened: a pipe would block the import. + guard let values = try? tagURL.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), + values.isSymbolicLink != true, values.isRegularFile == true, + let handle = try? FileHandle(forReadingFrom: tagURL) else { return nil } defer { try? handle.close() } guard let data = try? handle.read(upToCount: maximumKeywordFileByteCount), let list = String(data: data, encoding: .utf8) else { return nil } diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift b/apple/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift index 42195a88e9..c9099f8752 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import TableProImport +import TableProPluginKit struct SequelAceImporter: ForeignAppImporter { let id = "sequelace" @@ -14,6 +15,9 @@ struct SequelAceImporter: ForeignAppImporter { let appBundleIdentifier = "com.sequel-ace.sequel-ace" let readsPasswordsFromKeychain = true + private static let socketType = 1 + private static let awsIAMType = 3 + var resolveAppURL: @Sendable (_ bundleIdentifier: String) -> URL? = { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) } @@ -92,16 +96,11 @@ struct SequelAceImporter: ForeignAppImporter { return dict } + // The rule parseChildren reads by, so the count says what an import brings. private func countConnections(in children: [[String: Any]]) -> Int { - var count = 0 - for child in children { - if child["host"] != nil || (child["id"] is NSNumber && child["Children"] == nil) { - count += 1 - } else if let subChildren = child["Children"] as? [[String: Any]] { - count += countConnections(in: subChildren) - } + children.reduce(0) { total, child in + total + ((child["Children"] as? [[String: Any]]).map { countConnections(in: $0) } ?? 1) } - return count } private func parseChildren( @@ -127,16 +126,19 @@ struct SequelAceImporter: ForeignAppImporter { if includePasswords, !credentialsAborted { credentials = readCredentials(from: child, abortFlag: &credentialsAborted) } + var notes: [String] = [] + let settings = parseConnection(child, notes: ¬es) records.append(ForeignConnectionRecord( sourceId: (child["id"] as? NSNumber)?.stringValue ?? child["id"] as? String, - settings: parseConnection(child), + settings: settings, groupPath: groupPath, - credentials: credentials + credentials: credentials, + notes: notes )) } } - private func parseConnection(_ entry: [String: Any]) -> ExportableConnection { + private func parseConnection(_ entry: [String: Any], notes: inout [String]) -> ExportableConnection { let name = entry["name"] as? String ?? "Untitled" let host = entry["host"] as? String ?? "localhost" let port: Int @@ -152,7 +154,7 @@ struct SequelAceImporter: ForeignAppImporter { let connectionType = entry["type"] as? Int ?? 0 let sshConfig = parseSSHConfig(entry, connectionType: connectionType) - let sslConfig = parseSSLConfig(entry) + let sslConfig = parseSSLConfig(entry, connectionType: connectionType, notes: ¬es) return ExportableConnection( name: name, @@ -199,22 +201,27 @@ struct SequelAceImporter: ForeignAppImporter { ) } - private func parseSSLConfig(_ entry: [String: Any]) -> ExportableSSLConfig? { - let useSSL: Bool - if let intVal = entry["useSSL"] as? Int { - useSSL = intVal != 0 - } else if let boolVal = entry["useSSL"] as? Bool { - useSSL = boolVal - } else { - useSSL = false + // With SSL off Sequel Ace still asks libmysqlclient to prefer TLS, and requires it for the cleartext + // plugin and for AWS IAM, which also turns SSL on; over a socket the client leaves TLS off. + private func parseSSLConfig(_ entry: [String: Any], connectionType: Int, notes: inout [String]) -> ExportableSSLConfig? { + func flag(_ key: String) -> Bool { + if let number = entry[key] as? Int { + return number != 0 + } + return entry[key] as? Bool ?? false + } + let useSSL = flag("useSSL") + guard useSSL || connectionType != Self.socketType else { return nil } + let required = useSSL || connectionType == Self.awsIAMType + let mode: SSLMode = required || flag("enableClearTextPlugin") ? .required : .preferred + if !useSSL { + notes.append(ForeignImportNote.tlsFromDriverDefault(mode, appName: displayName)) } - guard useSSL else { return nil } - return ExportableSSLConfig( - mode: "Required", - caCertificatePath: entry["sslCACertFileLocation"] as? String, - clientCertificatePath: entry["sslCertificateFileLocation"] as? String, - clientKeyPath: entry["sslKeyFileLocation"] as? String + mode: mode.rawValue, + caCertificatePath: required ? entry["sslCACertFileLocation"] as? String : nil, + clientCertificatePath: required ? entry["sslCertificateFileLocation"] as? String : nil, + clientKeyPath: required ? entry["sslKeyFileLocation"] as? String : nil ) } diff --git a/apple/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift b/apple/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift index 2c28dd86e0..bdf03e0801 100644 --- a/apple/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift +++ b/apple/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift @@ -71,7 +71,8 @@ struct TablePlusImporter: ForeignAppImporter { func inventory() -> ForeignAppInventory { let prefs = viewSetting return ForeignAppInventory( - connections: loadEntries(from: connectionsDirectory(viewSetting: prefs))?.count ?? 0, + connections: loadEntries(from: connectionsDirectory(viewSetting: prefs))? + .count(where: { Self.connectionName($0) != nil }) ?? 0, savedQueries: TablePlusFavoriteReader.count(in: favoriteRoots(viewSetting: prefs)) ) } @@ -103,7 +104,8 @@ struct TablePlusImporter: ForeignAppImporter { for entry in entries { try Task.checkCancellation() do { - let settings = try parseConnection(entry) + var notes: [String] = [] + let settings = try parseConnection(entry, notes: ¬es) let connectionId = entry["ID"] as? String var credentials: ExportableCredentials? if request.includePasswords, !credentialsAborted, let connectionId { @@ -119,7 +121,8 @@ struct TablePlusImporter: ForeignAppImporter { sourceId: connectionId, settings: settings, groupPath: groupName.map { [$0] } ?? [], - credentials: credentials + credentials: credentials, + notes: notes )) } catch { Self.logger.warning("Skipping a TablePlus connection that could not be read") @@ -191,8 +194,13 @@ struct TablePlusImporter: ForeignAppImporter { return map } - private func parseConnection(_ entry: [String: Any]) throws -> ExportableConnection { - guard let name = entry["ConnectionName"] as? String else { + // An entry without a name is never imported, so the inventory skips it too. + private static func connectionName(_ entry: [String: Any]) -> String? { + entry["ConnectionName"] as? String + } + + private func parseConnection(_ entry: [String: Any], notes: inout [String]) throws -> ExportableConnection { + guard let name = Self.connectionName(entry) else { throw ForeignAppImportError.parseError("Missing ConnectionName") } @@ -239,7 +247,7 @@ struct TablePlusImporter: ForeignAppImporter { username: username, type: dbType, sshConfig: parseSSHConfig(entry), - sslConfig: parseSSLConfig(entry, driver: driver), + sslConfig: parseSSLConfig(entry, driver: driver, notes: ¬es), color: mapEnvironmentColor(entry["Enviroment"] as? String), additionalFields: additionalFields.isEmpty ? nil : additionalFields ) @@ -277,19 +285,25 @@ struct TablePlusImporter: ForeignAppImporter { return keyFileExists(PathPortability.expandHome(resolved)) ? resolved : "" } - private func parseSSLConfig(_ entry: [String: Any], driver: String) -> ExportableSSLConfig? { + private func parseSSLConfig(_ entry: [String: Any], driver: String, notes: inout [String]) -> ExportableSSLConfig? { guard entry.keys.contains("tLSMode") else { return nil } let tlsMode = entry["tLSMode"] as? Int ?? 0 let form = TablePlusTLSVocabulary.form(forDriver: driver) - guard tlsMode >= 0, tlsMode < form.modes.count else { return nil } + // A mode outside the form's list is TLS this cannot read, not TLS off. + guard tlsMode >= 0, tlsMode < form.modes.count else { return ExportableSSLConfig(mode: String(tlsMode)) } let paths = entry["TlsKeyPaths"] as? [String] ?? [] func certPath(_ slot: Int?) -> String? { guard let slot, slot < paths.count, !paths[slot].isEmpty else { return nil } return paths[slot] } + let mode = form.modes[tlsMode] + // The SQL Server form has no TLS picker, and TablePlus's FreeTDS asks for TLS whenever the server offers it. + if driver == "MicrosoftSQLServer" { + notes.append(ForeignImportNote.tlsFromDriverDefault(mode, appName: displayName)) + } return ExportableSSLConfig( - mode: form.modes[tlsMode].rawValue, + mode: mode.rawValue, caCertificatePath: certPath(form.slots.certificateAuthority), clientCertificatePath: certPath(form.slots.clientCertificate), clientKeyPath: certPath(form.slots.clientKey) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 8c0e1b7ef5..994b5c92fd 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -172339,6 +172339,9 @@ } } } + }, + "SSL %1$@ is on because %2$@ encrypts this connection by default" : { + }, "SSL Mode" : { "localizations" : { diff --git a/apple/TableProTests/Core/Services/ForeignApp/BeekeeperStudioImporterTests.swift b/apple/TableProTests/Core/Services/ForeignApp/BeekeeperStudioImporterTests.swift index 0848ad984e..f69c74b0b1 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/BeekeeperStudioImporterTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/BeekeeperStudioImporterTests.swift @@ -160,6 +160,94 @@ struct BeekeeperStudioImporterTests { #expect(importer.inventory() == ForeignAppInventory(connections: 1, savedQueries: 3)) } + @Test("inventory counts only the connections collect imports") + func inventorySkipsUntypedConnections() throws { + try makeDatabase(Self.connectionSchema + """ + INSERT INTO saved_connection (id, name, connectionType, host) VALUES (3, 'No type', NULL, 'h'); + """) + + let result = try importer.collect(.connectionsOnly) + + #expect(result.connections.map { $0.name } == ["Shop"]) + #expect(importer.inventory().connections == result.connections.count) + } + + @Test("inventory and collect agree on which saved queries are blank") + func blankQueriesAgree() throws { + try makeDatabase(Self.connectionSchema + """ + CREATE TABLE favorite_query (id INTEGER PRIMARY KEY, title TEXT, text TEXT); + INSERT INTO favorite_query (id, title, text) VALUES + (1, 'Orders', 'select * from orders;'), + (2, 'Form feed', char(12)), + (3, 'Blank', ' ' || char(9) || char(10) || char(13)); + """) + + let result = try importer.collect(.withSavedQueries) + + #expect(result.savedQueryNames == ["Orders", "Form feed"]) + #expect(importer.inventory().savedQueries == result.bundle.savedQueries.count) + } + + @Test("TLS verification follows what each engine's Beekeeper connector does") + func tlsVerificationPerEngine() throws { + // Beekeeper saves sslRejectUnauthorized and trustServerCertificate as 1 unless the user turns them off. + try makeDatabase(Self.connectionSchema + """ + INSERT INTO saved_connection (id, name, connectionType, host, ssl, sslCaFile, sslCertFile, sslKeyFile, + sslRejectUnauthorized, trustServerCertificate) VALUES + (11, 'PG no files', 'postgresql', 'pg', 1, NULL, NULL, NULL, 1, 1), + (12, 'PG with CA', 'postgresql', 'pg', 1, '/certs/ca.pem', NULL, NULL, 1, 1), + (13, 'PG unverified', 'postgresql', 'pg', 1, '/certs/ca.pem', NULL, NULL, 0, 1), + (14, 'MySQL no files', 'mysql', 'my', 1, NULL, NULL, NULL, 1, 1), + (15, 'SQL Server', 'sqlserver', 'ms', 1, NULL, NULL, NULL, 0, 1), + (16, 'SQL Server trusted', 'sqlserver', 'ms', 1, '/certs/ca.pem', '/certs/c.pem', '/certs/c.key', 0, 0), + (17, 'Redis', 'redis', 'cache', 1, NULL, NULL, NULL, 1, 1); + """) + + var modes: [String: PortableSSLMode] = [:] + for connection in try importer.collect(.connectionsOnly).connections { + modes[connection.name] = connection.sslConfig?.portableMode + } + + #expect(modes["PG no files"] == .required) + #expect(modes["PG with CA"] == .verifyIdentity) + #expect(modes["PG unverified"] == .required) + #expect(modes["MySQL no files"] == .required) + #expect(modes["SQL Server"] == .verifyIdentity) + #expect(modes["SQL Server trusted"] == .required) + #expect(modes["Redis"] == .verifyIdentity) + } + + @Test("With SSL off only SQL Server encrypts, as mssql does by default") + func tlsWithSSLOff() throws { + try makeDatabase(Self.connectionSchema + """ + INSERT INTO saved_connection (id, name, connectionType, host, ssl, sslRejectUnauthorized, trustServerCertificate) + VALUES + (21, 'SQL Server trusting', 'sqlserver', 'ms', 0, 1, 1), + (22, 'SQL Server checking', 'sqlserver', 'ms', 0, 1, 0), + (23, 'PG plain', 'postgresql', 'pg', 0, 1, 1), + (24, 'MySQL plain', 'mysql', 'my', 0, 1, 1); + """) + + let collected = try importer.collect(.connectionsOnly) + let connections = Dictionary(uniqueKeysWithValues: collected.connections.map { ($0.name, $0) }) + + let trusting = try #require(connections["SQL Server trusting"]?.sslConfig) + #expect(trusting.portableMode == .required) + #expect(trusting.caCertificatePath == nil) + #expect(connections["SQL Server checking"]?.sslConfig?.portableMode == .verifyIdentity) + #expect(connections["PG plain"]?.sslConfig == nil) + #expect(connections["MySQL plain"]?.sslConfig == nil) + + #expect(collected.notes(named: "SQL Server trusting") == [ + "SSL Required (skip verify) is on because Beekeeper Studio encrypts this connection by default" + ]) + #expect(collected.notes(named: "SQL Server checking") == [ + "SSL Verify Identity is on because Beekeeper Studio encrypts this connection by default" + ]) + #expect(collected.notes(named: "PG plain").isEmpty) + #expect(collected.notes(named: "MySQL plain").isEmpty) + } + @Test("Saved queries import as global queries in a folder named after the app") func savedQuerySupport() { guard case .reads(let caption) = importer.savedQuerySupport else { diff --git a/apple/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift b/apple/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift index 21f2d573e3..09aa18516d 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift @@ -31,15 +31,42 @@ struct DBeaverImporterTests { // MARK: - Fixture Helpers - private func writeDataSources(_ json: [String: Any]) throws { + private func writeDataSources(_ json: [String: Any], named fileName: String = "data-sources.json") throws { let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) - try data.write(to: projectDir.appendingPathComponent("data-sources.json")) + try data.write(to: projectDir.appendingPathComponent(fileName)) } - private func writeCredentials(_ credentials: [String: Any]) throws { + private func writeCredentials( + _ credentials: [String: Any], + named fileName: String = "credentials-config.json" + ) throws { let plaintext = try JSONSerialization.data(withJSONObject: credentials, options: .prettyPrinted) let encrypted = encryptWithDBeaverKey(plaintext) - try encrypted.write(to: projectDir.appendingPathComponent("credentials-config.json")) + try encrypted.write(to: projectDir.appendingPathComponent(fileName)) + } + + private func urlConnection( + _ name: String, + provider: String = "postgresql", + configuration: [String: Any] + ) -> [String: Any] { + ["provider": provider, "name": name, "configuration": configuration] + } + + private func tlsConnection( + _ name: String, + provider: String = "postgresql", + handler: String, + properties: [String: Any] + ) -> [String: Any] { + urlConnection(name, provider: provider, configuration: [ + "host": "db.example.com", + "handlers": [handler: ["type": "CONFIG", "enabled": true, "properties": properties] as [String: Any]] + ]) + } + + private func collectByName(_ request: ForeignImportRequest = .connectionsOnly) throws -> [String: ExportableConnection] { + Dictionary(uniqueKeysWithValues: try importer.collect(request).connections.map { ($0.name, $0) }) } private func encryptWithDBeaverKey(_ data: Data) -> Data { @@ -465,6 +492,94 @@ struct DBeaverImporterTests { #expect(result.bundle.credentials.isEmpty) } + // MARK: - Data source files + + @Test("collect reads every data-sources file with its own credentials file and one folder tree") + func testCollect_readsEveryDataSourcesFile() throws { + try writeDataSources(makeDataSourcesJSON( + connections: ["main-1": makeConnection(name: "Main", user: nil)], + folders: ["shared": ["description": "Shared Servers"]] + )) + try writeCredentials(["main-1": ["#connection": ["user": "main-user", "password": "main-pass"]]]) + try writeDataSources( + makeDataSourcesJSON(connections: ["sub-1": makeConnection(name: "Second", user: nil, folder: "shared")]), + named: "data-sources-2.json" + ) + try writeCredentials( + ["sub-1": ["#connection": ["user": "reporter", "password": "sub-pass"]]], + named: "credentials-config-2.json" + ) + try Data("not json".utf8).write(to: projectDir.appendingPathComponent("data-sources-broken.json")) + + #expect(importer.inventory().connections == 2) + let result = try importer.collect(.withPasswords) + #expect(result.connections.map { $0.name } == ["Main", "Second"]) + #expect(result.connections.map { $0.username } == ["main-user", "reporter"]) + #expect(result.bundle.credentials["main-1"]?.password == "main-pass") + #expect(result.bundle.credentials["sub-1"]?.password == "sub-pass") + #expect(result.groupPath(at: 1) == ["Shared Servers"]) + } + + @Test("A project with only an extra data-sources file is found") + func testCollect_projectWithOnlyAnExtraFile() throws { + try writeDataSources( + makeDataSourcesJSON(connections: ["team-1": makeConnection(name: "Team")]), + named: "data-sources-team.json" + ) + + #expect(importer.isAvailable()) + #expect(try importer.collect(.connectionsOnly).connections.map { $0.name } == ["Team"]) + } + + @Test("The first project by name is read, whatever order the folder lists them in") + func testCollect_readsProjectsInNameOrder() throws { + let workspace = projectDir.deletingLastPathComponent().deletingLastPathComponent() + for project in ["Beta", "Alpha"] { + let json = makeDataSourcesJSON(connections: ["\(project)-1": makeConnection(name: "\(project) PG")]) + let data = try JSONSerialization.data(withJSONObject: json) + let metadata = workspace.appendingPathComponent("\(project)/.dbeaver", isDirectory: true) + try FileManager.default.createDirectory(at: metadata, withIntermediateDirectories: true) + try data.write(to: metadata.appendingPathComponent("data-sources.json")) + } + + #expect(try importer.collect(.connectionsOnly).connections.map { $0.name } == ["Alpha PG"]) + } + + // MARK: - Tunnel credentials + + @Test("A tunnel's user and password come from the credentials file's network node") + func testCollect_tunnelSecretsFromNetworkNode() throws { + try writeDataSources(makeDataSourcesJSON(connections: [ + "ssh-1": makeConnection(name: "Tunnel", sshEnabled: true, sshHost: "bastion.example.com") + ])) + try writeCredentials(["ssh-1": ["network/ssh_tunnel": ["user": "tunnel-user", "password": "tunnel-secret"]]]) + + #expect(try importer.collect(.connectionsOnly).connections[0].sshConfig?.username == "tunnel-user") + let result = try importer.collect(.withPasswords) + #expect(result.connections[0].sshConfig?.username == "tunnel-user") + #expect(result.bundle.credentials["ssh-1"]?.sshPassword == "tunnel-secret") + #expect(result.bundle.credentials["ssh-1"]?.password == nil) + } + + @Test("A key tunnel's saved secret imports as the key passphrase, not the SSH password") + func testCollect_keyTunnelSecretIsThePassphrase() throws { + try writeDataSources(makeDataSourcesJSON(connections: [ + "key-1": makeConnection( + name: "Key tunnel", + sshEnabled: true, + sshHost: "bastion.example.com", + sshUsername: "deploy", + sshAuthType: "PUBLIC_KEY", + sshKeyPath: "~/.ssh/id_ed25519" + ) + ])) + try writeCredentials(["key-1": ["network/ssh_tunnel": ["password": "phrase"]]]) + + let credentials = try importer.collect(.withPasswords).bundle.credentials["key-1"] + #expect(credentials?.keyPassphrase == "phrase") + #expect(credentials?.sshPassword == nil) + } + // MARK: - Username (credentials-config.json) @Test("Username imports from credentials-config.json") @@ -667,8 +782,8 @@ struct DBeaverImporterTests { #expect(ssl?.mode == "Verify Identity") } - @Test("collect SSL enabled with empty mode defaults to Preferred") - func testCollect_sslEnabledEmptyModeDefaultsToPreferred() throws { + @Test("A plain ssl handler with no mode keeps TLS on, with a mode the review warns about") + func testCollect_sslEnabledEmptyModeIsUnread() throws { let connections: [String: [String: Any]] = [ "pg-1": makeConnection(name: "SSL Default", sslEnabled: true, sslMode: "") ] @@ -677,8 +792,199 @@ struct DBeaverImporterTests { let result = try importer.collect(.connectionsOnly) let ssl = result.connections[0].sslConfig - #expect(ssl != nil) - #expect(ssl?.mode == "Preferred") + #expect(ssl?.mode == "ssl") + #expect(ssl?.portableMode == nil) + } + + @Test("Each engine's TLS handler is read by its own name, with DBeaver's certificate keys") + func testCollect_engineTLSHandlers() throws { + try writeDataSources(makeDataSourcesJSON(connections: [ + "pg-require": tlsConnection("PG require", handler: "postgre_ssl", properties: ["sslMode": "require"]), + "pg-default": tlsConnection("PG default", handler: "postgre_ssl", properties: [ + "sslMode": "", "ssl.ca.cert": "", "rootCert": "/root.crt", "clientCert": "/old.crt" + ]), + "pg-certs": tlsConnection("PG certs", handler: "postgre_ssl", properties: [ + "sslMode": "verify-ca", + "ssl.ca.cert": "/ca.pem", + "ssl.client.cert": "/cert.pem", + "ssl.client.key": "/key.pem" + ]), + "pg-off": tlsConnection("PG off", handler: "postgre_ssl", properties: ["sslMode": "disable"]), + "mysql-required": tlsConnection("MySQL required", provider: "mysql", handler: "mysql_ssl", properties: [ + "ssl.require": "true", "ssl.verify.server": "false" + ]), + "mysql-verified": tlsConnection("MySQL verified", provider: "mysql", handler: "mysql_ssl", properties: [ + "ssl.verify.server": true, "ssl.ca.cert": "/certs/mysql-ca.pem" + ]), + "clickhouse": tlsConnection("ClickHouse", provider: "clickhouse", handler: "clickhouse-ssl", properties: [ + "ssl.mode": "NONE" + ]) + ])) + + let byName = try collectByName() + #expect(byName["PG require"]?.sslConfig?.mode == "Required") + let pgDefault = byName["PG default"]?.sslConfig + #expect(pgDefault?.mode == "Verify Identity") + #expect(pgDefault?.caCertificatePath == "/root.crt") + #expect(pgDefault?.clientCertificatePath == "/old.crt") + let certs = byName["PG certs"]?.sslConfig + #expect(certs?.mode == "Verify CA") + #expect(certs?.caCertificatePath == "/ca.pem") + #expect(certs?.clientCertificatePath == "/cert.pem") + #expect(certs?.clientKeyPath == "/key.pem") + #expect(byName["PG off"]?.sslConfig == nil) + #expect(byName["MySQL required"]?.sslConfig?.mode == "Required") + #expect(byName["MySQL verified"]?.sslConfig?.mode == "Verify CA") + #expect(byName["MySQL verified"]?.sslConfig?.caCertificatePath == "/certs/mysql-ca.pem") + #expect(byName["ClickHouse"]?.sslConfig?.mode == "Required") + } + + @Test("A TLS handler whose mode is not read keeps TLS on, with a mode the review warns about") + func testCollect_unreadTLSHandler() throws { + try writeDataSources(makeDataSourcesJSON(connections: [ + "mssql": tlsConnection("SQL Server", provider: "sqlserver", handler: "mssql_ssl", properties: [ + "ssl.method": "KEYSTORE", "ssl.keystore": "/certs/store.jks" + ]), + "pg-unknown": tlsConnection("PG unknown", handler: "postgre_ssl", properties: ["sslMode": "sometimes"]), + "ch-lax": tlsConnection("ClickHouse lax", provider: "clickhouse", handler: "clickhouse-ssl", properties: [ + "ssl.mode": "LAX" + ]) + ])) + + let byName = try collectByName() + #expect(byName["SQL Server"]?.sslConfig?.mode == "mssql_ssl") + #expect(byName["PG unknown"]?.sslConfig?.mode == "sometimes") + #expect(byName["ClickHouse lax"]?.sslConfig?.mode == "LAX") + #expect(byName.values.allSatisfy { $0.sslConfig != nil && $0.sslConfig?.portableMode == nil }) + } + + @Test("A pgjdbc connection with no TLS handler takes the driver's default, preferred") + func testCollect_pgjdbcWithoutHandlerPrefersTLS() throws { + func pgjdbc(_ name: String, driver: String = "postgres-jdbc", _ configuration: [String: Any]) -> [String: Any] { + ["provider": "postgresql", "driver": driver, "name": name, "configuration": configuration] + } + try writeDataSources(makeDataSourcesJSON(connections: [ + "plain": pgjdbc("Plain", ["host": "db.example.com"]), + "url-off": pgjdbc("URL off", [ + "host": "db.example.com", "url": "jdbc:postgresql://db.example.com/app?sslmode=disable" + ]), + "property": pgjdbc("Property", ["host": "db.example.com", "properties": ["sslmode": "require"]]), + "handler-off": pgjdbc("Handler off", [ + "host": "db.example.com", + "handlers": ["postgre_ssl": ["enabled": true, "properties": ["sslMode": "disable"]]] + ]), + "other-driver": pgjdbc("Other driver", driver: "postgres-other", ["host": "db.example.com"]), + "mysql": ["provider": "mysql", "driver": "mysql8", "name": "MySQL", "configuration": ["host": "db.example.com"]] + ])) + + let byName = try collectByName() + #expect(byName["Plain"]?.sslConfig == ExportableSSLConfig(mode: "Preferred")) + #expect(byName["URL off"]?.sslConfig == nil) + #expect(byName["Property"]?.sslConfig?.mode == "sslmode") + #expect(byName["Property"]?.sslConfig?.portableMode == nil) + #expect(byName["Handler off"]?.sslConfig == nil) + #expect(byName["Other driver"]?.sslConfig == nil) + #expect(byName["MySQL"]?.sslConfig == nil) + + let collected = try importer.collect(.connectionsOnly) + #expect(collected.notes(named: "Plain") == ["SSL Preferred is on because DBeaver encrypts this connection by default"]) + let unnoted = ["URL off", "Property", "Handler off", "Other driver", "MySQL"] + #expect(unnoted.allSatisfy { collected.notes(named: $0).isEmpty }) + } + + // MARK: - URLs + + @Test("A URL-only connection keeps what its URL names, never the URL or its password") + func testCollect_urlOnlyConnectionNeverKeepsTheURL() throws { + let url = "jdbc:postgresql://admin:hunter2@db.cloud.example:6543/app?password=hunter2&sslmode=require" + try writeDataSources(makeDataSourcesJSON(connections: [ + "cloud": urlConnection("Cloud", configuration: ["url": url]) + ])) + + let connection = try #require(try importer.collect(.connectionsOnly).connections.first) + #expect(connection.host == "db.cloud.example") + #expect(connection.port == 6543) + #expect(connection.database == "app") + #expect(connection.username == "admin") + #expect(connection.sslConfig?.mode == "Required") + let encoded = String(decoding: try JSONEncoder().encode(connection), as: UTF8.self) + #expect(!encoded.contains("hunter2")) + } + + @Test("In URL mode the URL names the server over leftover manual fields") + func testCollect_urlModeURLWins() throws { + try writeDataSources(makeDataSourcesJSON(connections: [ + "url": urlConnection("URL mode", configuration: [ + "host": "old.example.com", "port": "6000", "database": "old", + "url": "jdbc:postgresql://new.example.com/current", "configurationType": "URL" + ]), + "manual": urlConnection("Manual mode", configuration: [ + "host": "manual.example.com", "port": "5433", "database": "manual", + "url": "jdbc:postgresql://url.example.com:6543/fromurl", "configurationType": "MANUAL" + ]), + "unparsed": urlConnection("Unparsed URL", configuration: [ + "host": "db", "port": "6000", "url": "not a url", "configurationType": "URL" + ]) + ])) + + let byName = try collectByName() + let urlMode = try #require(byName["URL mode"]) + #expect(urlMode.host == "new.example.com") + #expect(urlMode.port == 5432) + #expect(urlMode.database == "current") + let manual = try #require(byName["Manual mode"]) + #expect(manual.host == "manual.example.com") + #expect(manual.port == 5433) + #expect(manual.database == "manual") + let unparsed = try #require(byName["Unparsed URL"]) + #expect(unparsed.host == "db") + #expect(unparsed.port == 6000) + } + + @Test("A connection keeps the TLS its URL asks for and marks any other TLS switch unread") + func testCollect_urlTLS() throws { + try writeDataSources(makeDataSourcesJSON(connections: [ + "verified": urlConnection("Verified", configuration: [ + "url": "jdbc:postgresql://db.example.com/app?sslmode=verify-full" + ]), + "switch": urlConnection("Switch", configuration: ["url": "jdbc:postgresql://db.example.com/app?ssl=true"]), + "https": urlConnection("HTTPS", provider: "clickhouse", configuration: [ + "url": "jdbc:clickhouse:https://ch.example.com:8443/default" + ]), + "plain": urlConnection("Plain", configuration: ["url": "jdbc:postgresql://db.example.com/app"]) + ])) + + let byName = try collectByName() + #expect(byName["Verified"]?.sslConfig?.mode == "Verify Identity") + #expect(byName["Switch"]?.sslConfig != nil) + #expect(byName["Switch"]?.sslConfig?.portableMode == nil) + #expect(byName["HTTPS"]?.sslConfig?.mode == "Verify Identity") + #expect(byName["HTTPS"]?.host == "ch.example.com") + #expect(byName["HTTPS"]?.port == 8443) + #expect(byName["Plain"]?.sslConfig == nil) + } + + @Test("A MongoDB URL keeps its seed list and auth database as MongoDB fields") + func testCollect_mongoURLFields() throws { + try writeDataSources(makeDataSourcesJSON(connections: [ + "atlas": urlConnection("Atlas", provider: "mongodb", configuration: [ + "url": "mongodb+srv://cluster0.example.net/app" + ]), + "mongo": urlConnection("Mongo", provider: "mongodb", configuration: [ + "host": "mongo.example.com", "port": "27017", "database": "shop", + "url": "mongodb://mongo.example.com:27017/shop?authSource=admin" + ]) + ])) + + let byName = try collectByName() + let atlas = try #require(byName["Atlas"]) + #expect(atlas.host == "cluster0.example.net") + #expect(atlas.database == "app") + #expect(atlas.additionalFields?["mongoUseSrv"] == "true") + #expect(atlas.sslConfig?.mode == "Verify Identity") + let mongo = try #require(byName["Mongo"]) + #expect(mongo.host == "mongo.example.com") + #expect(mongo.additionalFields?["mongoAuthSource"] == "admin") } @Test("collect parses SSL certificate paths") diff --git a/apple/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift b/apple/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift index 4299138162..b3304472e2 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift @@ -312,6 +312,233 @@ struct DataGripImporterTests { #expect(ssl.clientKeyPath == "\(NSHomeDirectory())/certs/client.key") } + @Test("A URL that asks for TLS keeps it when the data source has no SSL settings") + func urlTLSWithoutSSLSettings() throws { + try writeDataSources([ + source(uuid: "1", name: "verified", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://db/app?sslmode=verify-full"), + source(uuid: "2", name: "unread", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://db/app?ssl=true"), + source(uuid: "3", name: "https", driverRef: "clickhouse", jdbcURL: "jdbc:clickhouse:https://ch.example:8443/default"), + source(uuid: "4", name: "plain", driverRef: "clickhouse", jdbcURL: "jdbc:clickhouse://ch.example:8123/default"), + source(uuid: "5", name: "off", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://db/app?sslmode=disable") + ]) + + let connections = Dictionary( + uniqueKeysWithValues: try importer.collect(.connectionsOnly).connections.map { ($0.name, $0) } + ) + + #expect(connections["verified"]?.sslConfig?.portableMode == .verifyIdentity) + let unread = try #require(connections["unread"]?.sslConfig) + #expect(unread.portableMode == nil) + #expect(unread.mode == "ssl") + #expect(connections["https"]?.sslConfig?.portableMode == .verifyIdentity) + #expect(connections["https"]?.port == 8_443) + #expect(connections["plain"]?.sslConfig == nil) + #expect(connections["off"]?.sslConfig == nil) + } + + @Test("With no SSL settings and no TLS in the URL, the driver's own default decides") + func driverDefaultTLS() throws { + try writeDataSources([ + source(uuid: "1", name: "pg", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://db/app"), + source(uuid: "2", name: "mysql", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://db:3306/app"), + source(uuid: "3", name: "mssql", driverRef: "sqlserver.ms", jdbcURL: "jdbc:sqlserver://db:1433;databaseName=app"), + source(uuid: "4", name: "mariadb", driverRef: "mariadb", jdbcURL: "jdbc:mariadb://db:3306/app"), + source(uuid: "5", name: "jtds", driverRef: "sqlserver.jtds", jdbcURL: "jdbc:jtds:sqlserver://db:1433/app"), + source( + uuid: "6", name: "unchecked", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://db/app", + extra: "false" + ), + source( + uuid: "7", name: "trusting", driverRef: "sqlserver.ms", + jdbcURL: "jdbc:sqlserver://db:1433;databaseName=app;trustServerCertificate=true" + ), + source( + uuid: "8", name: "property", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://db/app", + extra: "" + ), + source( + uuid: "9", name: "other property", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://db/app", + extra: "" + ) + ]) + + let collected = try importer.collect(.connectionsOnly) + let connections = Dictionary(uniqueKeysWithValues: collected.connections.map { ($0.name, $0) }) + + #expect(connections["pg"]?.sslConfig?.portableMode == .preferred) + #expect(connections["mysql"]?.sslConfig?.portableMode == .preferred) + #expect(connections["mssql"]?.sslConfig?.portableMode == .verifyIdentity) + #expect(connections["mariadb"]?.sslConfig == nil) + #expect(connections["jtds"]?.sslConfig == nil) + #expect(connections["unchecked"]?.sslConfig?.portableMode == .preferred) + let trusting = try #require(connections["trusting"]?.sslConfig) + #expect(trusting.portableMode == nil) + let property = try #require(connections["property"]?.sslConfig) + #expect(property.portableMode == nil) + #expect(property.mode == "sslmode") + #expect(connections["other property"]?.sslConfig?.portableMode == .preferred) + + let preferred = "SSL Preferred is on because DataGrip encrypts this connection by default" + #expect(collected.notes(named: "pg") == [preferred]) + #expect(collected.notes(named: "mysql") == [preferred]) + #expect(collected.notes(named: "unchecked") == [preferred]) + #expect(collected.notes(named: "other property") == [preferred]) + #expect(collected.notes(named: "mssql") == [ + "SSL Verify Identity is on because DataGrip encrypts this connection by default" + ]) + #expect(["mariadb", "jtds", "trusting", "property"].allSatisfy { collected.notes(named: $0).isEmpty }) + } + + @Test("A section the local file disables turns off the shared one, and an absent one keeps it") + func localFileDisablesSharedSections() throws { + let sections = """ + trueSSH1 + trueREQUIRE + """ + try writeDataSources([ + source(uuid: "off", name: "off", driverRef: "mariadb", jdbcURL: "jdbc:mariadb://h:3306/db", extra: sections), + source(uuid: "kept", name: "kept", driverRef: "mariadb", jdbcURL: "jdbc:mariadb://h:3306/db", extra: sections) + ]) + try writeLocalDataSources([ + localSource(uuid: "off", extra: """ + falseSSH1 + false + """), + localSource(uuid: "kept", userName: "me") + ]) + try writeSSHConfigs([""]) + + let connections = Dictionary( + uniqueKeysWithValues: try importer.collect(.connectionsOnly).connections.map { ($0.name, $0) } + ) + + #expect(connections["off"]?.sshConfig == nil) + #expect(connections["off"]?.sslConfig == nil) + #expect(connections["kept"]?.sshConfig?.host == "bastion") + #expect(connections["kept"]?.sslConfig?.portableMode == .required) + } + + // MARK: - URL Forms + + @Test("A native MongoDB seed list keeps its server, seed list and auth database") + func mongoSeedList() throws { + try writeDataSources([ + source( + uuid: "1", name: "atlas", driverRef: "mongo", + jdbcURL: "mongodb+srv://cluster0.example.net/app?authSource=admin&retryWrites=true" + ), + source(uuid: "2", name: "direct", driverRef: "mongo", jdbcURL: "mongodb://mongo.example.com:27018/shop") + ]) + + let connections = Dictionary( + uniqueKeysWithValues: try importer.collect(.connectionsOnly).connections.map { ($0.name, $0) } + ) + + let atlas = try #require(connections["atlas"]) + #expect(atlas.host == "cluster0.example.net") + #expect(atlas.database == "app") + #expect(atlas.additionalFields == ["mongoUseSrv": "true", "mongoAuthSource": "admin"]) + #expect(atlas.sslConfig?.portableMode == .verifyIdentity) + let direct = try #require(connections["direct"]) + #expect(direct.host == "mongo.example.com") + #expect(direct.port == 27_018) + #expect(direct.additionalFields == nil) + } + + @Test("The URL's user fills in a data source without a user name") + func urlUser() throws { + try writeDataSources([ + source(uuid: "1", name: "info", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://alice%40corp:pw@h:3306/a"), + source(uuid: "2", name: "param", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h/team%20db?user=Bob"), + source(uuid: "3", name: "named", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h/db?user=Bob", userName: "carol") + ]) + + let connections = Dictionary( + uniqueKeysWithValues: try importer.collect(.connectionsOnly).connections.map { ($0.name, $0) } + ) + + #expect(connections["info"]?.username == "alice@corp") + #expect(connections["param"]?.username == "Bob") + #expect(connections["param"]?.database == "team db") + #expect(connections["named"]?.username == "carol") + } + + @Test("A BigQuery source names its project") + func bigQueryProject() throws { + try writeDataSources([ + source( + uuid: "1", name: "bq", driverRef: "bigquery", + jdbcURL: "jdbc:bigquery://https://www.googleapis.com/bigquery/v2;ProjectId=my-project" + ) + ]) + + let connection = try #require(try importer.collect(.connectionsOnly).connections.first) + #expect(connection.database == "my-project") + #expect(connection.host == "https://www.googleapis.com/bigquery/v2") + } + + // MARK: - Files + + @Test("A project whose dataSources.xml links to a device is skipped without reading it") + func linkedDeviceIsSkipped() throws { + let project = root.appendingPathComponent("linked-project") + let idea = project.appendingPathComponent(".idea") + try FileManager.default.createDirectory(at: idea, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink( + at: idea.appendingPathComponent("dataSources.xml"), + withDestinationURL: URL(fileURLWithPath: "/dev/zero") + ) + try ForeignFixture.write( + "", + to: optionsDir.appendingPathComponent("recentProjects.xml") + ) + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + + #expect(importer.inventory() == ForeignAppInventory(connections: 1, savedQueries: 0)) + } + + @Test("A dataSources.xml past 16 MiB is not read") + func oversizedDataSourcesFile() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a"), + "" + ]) + + #expect(importer.inventory().connections == 0) + } + + @Test("Only a regular file under the cap is read") + func foreignAppFileReadsRegularFilesOnly() throws { + let regular = root.appendingPathComponent("settings.xml") + try Data("".utf8).write(to: regular) + #expect(try ForeignAppFile.read(regular) == Data("".utf8)) + + let missing = root.appendingPathComponent("missing.xml") + do { + _ = try ForeignAppFile.read(missing) + Issue.record("Expected a missing file to throw") + } catch { + #expect(ForeignAppFile.isMissing(error)) + } + + let pipe = root.appendingPathComponent("pipe.xml") + try #require(mkfifo(pipe.path, 0o600) == 0) + #expect(throws: ForeignAppFile.ReadError.notRegularFile) { try ForeignAppFile.read(pipe) } + + let device = root.appendingPathComponent("device.xml") + try FileManager.default.createSymbolicLink(at: device, withDestinationURL: URL(fileURLWithPath: "/dev/zero")) + #expect(throws: ForeignAppFile.ReadError.notRegularFile) { try ForeignAppFile.read(device) } + + let huge = root.appendingPathComponent("huge.xml") + #expect(FileManager.default.createFile(atPath: huge.path, contents: nil)) + let handle = try FileHandle(forWritingTo: huge) + try handle.truncate(atOffset: UInt64(ForeignAppFile.maximumByteCount + 1)) + try handle.close() + #expect(throws: ForeignAppFile.ReadError.tooLarge) { try ForeignAppFile.read(huge) } + } + // MARK: - Groups & Dedup @Test("group-name attribute becomes a group") diff --git a/apple/TableProTests/Core/Services/ForeignApp/ForeignBundleAssemblyTests.swift b/apple/TableProTests/Core/Services/ForeignApp/ForeignBundleAssemblyTests.swift index 2a55729c12..4f0cf5d6c3 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/ForeignBundleAssemblyTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/ForeignBundleAssemblyTests.swift @@ -78,6 +78,19 @@ struct ForeignBundleAssemblyTests { #expect(result.bundle.connections.map { $0.ref } == ["abc", "row-1", "abc-2", "row-3", "row-1-4"]) } + @Test("A record's notes stay under its own ref, a repeated source id included, and are never a field") + func connectionNotes() throws { + var first = record("A", sourceId: "abc") + first.notes = ["First note"] + var repeated = record("C", sourceId: "abc") + repeated.notes = ["Second note"] + + let result = try collect([first, record("B", sourceId: "plain"), repeated]) + + #expect(result.connectionNotes == ["abc": ["First note"], "abc-2": ["Second note"]]) + #expect(result.connections.allSatisfy { $0.additionalFields == nil }) + } + @Test("Credentials are keyed by the connection's ref and empty ones are dropped") func credentialsFollowRefs() throws { let result = try collect([ diff --git a/apple/TableProTests/Core/Services/ForeignApp/ForeignImportTestSupport.swift b/apple/TableProTests/Core/Services/ForeignApp/ForeignImportTestSupport.swift index ed3c3f44b1..730fac2992 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/ForeignImportTestSupport.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/ForeignImportTestSupport.swift @@ -31,6 +31,10 @@ extension CollectedImport { bundle.connections.first { $0.settings.name == name }?.ref } + func notes(named name: String) -> [String] { + connectionRef(named: name).flatMap { connectionNotes[$0] } ?? [] + } + func savedQuery(named name: String) -> BundleSavedQuery? { bundle.savedQueries.first { $0.name == name } } diff --git a/apple/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift b/apple/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift index 0e9874ae6a..b0b8836b4f 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift @@ -5,6 +5,8 @@ import Foundation @testable import TablePro +import TableProImport +import TableProPluginKit import Testing struct JDBCConnectionStringTests { @@ -161,4 +163,117 @@ struct JDBCConnectionStringTests { func notJdbc() { #expect(JDBCConnectionString.parse(url: "mysql://x/y", subprotocol: "mysql") == nil) } + + @Test("A BigQuery URL names the API host and the project") + func bigQuery() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:bigquery://https://www.googleapis.com/bigquery/v2;ProjectId=my-project;OAuthType=3", + subprotocol: "bigquery" + ) + #expect(endpoint?.host == "https://www.googleapis.com/bigquery/v2") + #expect(endpoint?.port == nil) + #expect(endpoint?.database == "my-project") + } + + @Test("A native MongoDB URL names its first host and database") + func nativeMongo() { + let endpoint = JDBCConnectionString.parse( + url: "mongodb://app:secret@mongo1.example:27018,mongo2.example/shop?replicaSet=rs0", + subprotocol: "" + ) + #expect(endpoint?.host == "mongo1.example") + #expect(endpoint?.port == 27_018) + #expect(endpoint?.database == "shop") + #expect(endpoint?.username == "app") + + let seeded = JDBCConnectionString.parse(url: "mongodb+srv://cluster0.example.net/app", subprotocol: "") + #expect(seeded?.host == "cluster0.example.net") + #expect(seeded?.port == nil) + } + + @Test("A MongoDB URL keeps its seed list and its auth database as driver fields") + func mongoURLFields() { + let fields = JDBCConnectionString.urlFields("mongodb+srv://cluster0.example.net/app?authSource=admin&retryWrites=true") + #expect(fields == ["mongoUseSrv": "true", "mongoAuthSource": "admin"]) + #expect(JDBCConnectionString.urlFields("jdbc:mongodb://h/app?authSource=ops") == ["mongoAuthSource": "ops"]) + #expect(JDBCConnectionString.urlFields("jdbc:postgresql://db/app?authSource=admin").isEmpty) + } + + @Test("A URL names its user, decoded, but never its password") + func urlUser() { + let endpoint = JDBCConnectionString.parse(url: "mongodb://alice%40corp:s3cret@db.example/shop", subprotocol: "") + #expect(endpoint?.username == "alice@corp") + #expect(endpoint?.host == "db.example") + #expect(JDBCConnectionString.parse(url: "jdbc:postgresql://db/app", subprotocol: "postgresql")?.username == nil) + } + + @Test("A user parameter names the user in its own case") + func userParameter() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:postgresql://db/app?user=Alice&sslmode=require", + subprotocol: "postgresql" + ) + #expect(endpoint?.username == "Alice") + let server = JDBCConnectionString.parse( + url: "jdbc:sqlserver://db:1433;databaseName=app;user=Sales", + subprotocol: "sqlserver" + ) + #expect(server?.username == "Sales") + } + + @Test("A SQL Server instance keeps the port named after it") + func sqlServerInstancePort() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:sqlserver://db\\SALES:1444;databaseName=app", subprotocol: "sqlserver") + #expect(endpoint?.host == "db") + #expect(endpoint?.port == 1_444) + #expect(endpoint?.database == "app") + let bare = JDBCConnectionString.parse(url: "jdbc:sqlserver://db\\SALES;databaseName=app", subprotocol: "sqlserver") + #expect(bare?.host == "db") + #expect(bare?.port == nil) + } + + @Test("An escaped database name is decoded") + func escapedDatabase() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:postgresql://db/team%20db?ssl=true", subprotocol: "postgresql") + #expect(endpoint?.database == "team db") + #expect(JDBCConnectionString.percentDecoded("100%zz%4") == "100%zz%4") + } + + @Test("Only sslmode, a secure scheme and a seed list are read; every other TLS switch is unread") + func urlTLS() { + #expect(JDBCConnectionString.tls(of: "jdbc:postgresql://db/app?sslmode=verify-full") == .mode(.verifyIdentity)) + #expect(JDBCConnectionString.tls(of: "jdbc:postgresql://db/app?sslmode=require") == .mode(.required)) + #expect(JDBCConnectionString.tls(of: "jdbc:mariadb://db/app?sslMode=trust") == .mode(.required)) + #expect(JDBCConnectionString.tls(of: "jdbc:mysql://db/app?sslMode=VERIFY_IDENTITY") == .mode(.verifyIdentity)) + #expect(JDBCConnectionString.tls(of: "jdbc:postgresql://db/app?sslmode=disable") == .mode(.disabled)) + #expect(JDBCConnectionString.tls(of: "mongodb+srv://cluster.example/app") == .mode(.verifyIdentity)) + #expect(JDBCConnectionString.tls(of: "jdbc:clickhouse:https://db.example:8443/default") == .mode(.verifyIdentity)) + #expect(JDBCConnectionString.tls(of: "jdbc:clickhouse:http://db.example:8123/default") == JDBCConnectionString.TLS.none) + #expect(JDBCConnectionString.tls(of: "jdbc:postgresql://db/app") == JDBCConnectionString.TLS.none) + #expect(JDBCConnectionString.tls(of: "mongodb://db/app") == JDBCConnectionString.TLS.none) + let unread = [ + "jdbc:postgresql://db/app?ssl=true", + "jdbc:postgresql://db/app?sslmode=verify-full&sslrootcert=/ca.pem", + "jdbc:mysql://db/app?useSSL=true&requireSSL=false", + "jdbc:sqlserver://db:1433;databaseName=app;encrypt=true", + "mongodb+srv://cluster.example/app?tls=false", + "jdbc:postgresql://db/app?sslmode=sometimes", + "jdbc:postgresql://db/app?sslmode=require&sslpassword=hunter2" + ] + for url in unread { + guard case .unread(let written) = JDBCConnectionString.tls(of: url) else { + Issue.record("Expected \(url) to be unread") + continue + } + #expect(PortableSSLMode(carrying: written) == nil, "\(url)") + #expect(!written.contains("hunter2"), "\(url)") + } + } + + @Test("A secure scheme still names its host and port") + func secureSchemeEndpoint() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:clickhouse:https://db.example:8443/default", subprotocol: "clickhouse") + #expect(endpoint?.host == "db.example") + #expect(endpoint?.port == 8_443) + } } diff --git a/apple/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift b/apple/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift index 7b434f475c..1948b44826 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift @@ -65,6 +65,42 @@ struct JetBrainsCredentialStoreTests { } } + @Test("A raw pdb.pwd unlocks c.kdbx when c.pwd is missing") + func rawLegacyMainKey() throws { + let uuid = "legacy-\(UUID().uuidString)" + let mainKey = KdbxTestFixture.randomBytes(64) + let service = JetBrainsCredentialStore.serviceName(forDataSourceUUID: uuid) + try KdbxTestFixture.makeKdbx(mainKey: mainKey, title: service, userName: "u", password: "legacy-secret") + .write(to: configDir.appendingPathComponent("c.kdbx")) + try Data(KdbxTestFixture.builtInBlob(mainKey: mainKey)).write(to: configDir.appendingPathComponent("pdb.pwd")) + + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .found(let password) = store.password(forDataSourceUUID: uuid) else { + Issue.record("Expected the pdb.pwd main key to open c.kdbx") + return + } + #expect(password == "legacy-secret") + } + + @Test("pdb.pwd is ignored while c.pwd exists") + func legacyMainKeyIgnoredBesideCurrent() throws { + let uuid = "legacy-\(UUID().uuidString)" + let mainKey = KdbxTestFixture.randomBytes(64) + let service = JetBrainsCredentialStore.serviceName(forDataSourceUUID: uuid) + try KdbxTestFixture.makeKdbx(mainKey: mainKey, title: service, userName: "u", password: "legacy-secret") + .write(to: configDir.appendingPathComponent("c.kdbx")) + try "encryption: BUILT_IN\nvalue: !!binary AAAA\n" + .write(to: configDir.appendingPathComponent("c.pwd"), atomically: true, encoding: .utf8) + try KdbxTestFixture.makeMainKeyFile(mainKey: mainKey) + .write(to: configDir.appendingPathComponent("pdb.pwd"), atomically: true, encoding: .utf8) + + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .notFound = store.password(forDataSourceUUID: uuid) else { + Issue.record("Expected pdb.pwd to be ignored while c.pwd exists") + return + } + } + @Test("ssh password service name matches DataGrip's keychain format") func sshPasswordServiceName() { #expect(JetBrainsCredentialStore.sshPasswordServiceName(host: "localhost", port: 22, configId: "cfg") diff --git a/apple/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift b/apple/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift index 76795b4015..6ef2f0eb23 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift @@ -66,4 +66,61 @@ struct KdbxDatabaseTests { _ = try KdbxDatabase.read(fileData: Data([0x00, 0x01, 0x02, 0x03]), mainKey: []) } } + + @Test("A numeric header field shorter than its type is malformed, not read past its end") + func shortNumericHeaderField() { + var file = KdbxTestFixture.le32(0x9AA2_D903) + KdbxTestFixture.le32(0xB54B_FB67) + KdbxTestFixture.le32(0x0003_0001) + file += [6, 2, 0, 0xff, 0xff] + file += [0, 0, 0] + #expect(throws: KdbxError.malformedHeader) { + _ = try KdbxDatabase.read(fileData: Data(file), mainKey: Array("key".utf8)) + } + } + + @Test("A protected value of an unknown inner stream is never taken as the password") + func unknownInnerStreamPassword() throws { + let mainKey: [UInt8] = Array("unknown-stream-key".utf8) + let title = "IntelliJ Platform DB \u{2014} unknown" + let fileData = KdbxTestFixture.makeKdbx( + mainKey: mainKey, + title: title, + userName: "dbuser", + password: "p@ssw0rd!", + innerStreamID: 99 + ) + + let entry = try #require(try KdbxDatabase.read(fileData: fileData, mainKey: mainKey).first { $0.title == title }) + + #expect(entry.password.isEmpty) + } + + @Test("A key derivation past the round limit is refused before any work") + func transformRoundsPastLimit() { + let mainKey: [UInt8] = Array("rounds-key".utf8) + let fileData = KdbxTestFixture.replacingTransformRounds( + in: KdbxTestFixture.makeKdbx(mainKey: mainKey, title: "t", userName: "u", password: "p"), + with: 50_000_001 + ) + + #expect(throws: KdbxError.malformedHeader) { + _ = try KdbxDatabase.read(fileData: fileData, mainKey: mainKey) + } + } + + @Test("A compressed body that inflates past 16 MiB is refused") + func oversizedCompressedBody() { + let mainKey: [UInt8] = Array("inflating-key".utf8) + let fileData = KdbxTestFixture.makeKdbx( + mainKey: mainKey, + title: "t", + userName: "u", + password: "p", + compressed: true, + paddingByteCount: 16 * 1_024 * 1_024 + ) + + #expect(throws: KdbxError.corruptedData) { + _ = try KdbxDatabase.read(fileData: fileData, mainKey: mainKey) + } + } } diff --git a/apple/TableProTests/Core/Services/ForeignApp/KdbxTestFixture.swift b/apple/TableProTests/Core/Services/ForeignApp/KdbxTestFixture.swift index 58f1abadc4..6c88017479 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/KdbxTestFixture.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/KdbxTestFixture.swift @@ -8,18 +8,20 @@ import Compression import Foundation @testable import TablePro -/// Builds a synthetic KDBX 3.1 file matching JetBrains' layout so the reader can -/// be exercised end to end without a real `c.kdbx`. ChaCha20 inner stream, -/// AES-256-CBC body, AES-KDF key transform, with an optional gzip payload to -/// cover the compressed form real files use. +// A KDBX 3.1 file laid out as JetBrains writes it: AES-KDF, an AES-256-CBC body, a ChaCha20 inner stream. enum KdbxTestFixture { + static let chaCha20StreamID: UInt32 = 3 + + // An inner stream id no reader knows writes protected values as plain base64. static func makeKdbx( mainKey: [UInt8], title: String, userName: String, password: String, rounds: UInt64 = 60, - compressed: Bool = false + compressed: Bool = false, + innerStreamID: UInt32 = chaCha20StreamID, + paddingByteCount: Int = 0 ) -> Data { let mainSeed = randomBytes(32) let transformSeed = randomBytes(32) @@ -29,7 +31,9 @@ enum KdbxTestFixture { let digest = sha512(protectedStreamKey) var cipher = ChaCha20Cipher(key: Array(digest[0..<32]), nonce: Array(digest[32..<44])) - let protectedPassword = Data(cipher.process(Array(password.utf8))).base64EncodedString() + let passwordBytes = innerStreamID == chaCha20StreamID ? cipher.process(Array(password.utf8)) : Array(password.utf8) + let protectedPassword = Data(passwordBytes).base64EncodedString() + let padding = paddingByteCount > 0 ? "" : "" let xml = """ @@ -38,7 +42,7 @@ enum KdbxTestFixture { Title\(title) UserName\(userName) Password\(protectedPassword) - + \(padding) """ let payload = compressed ? gzip(Array(xml.utf8)) : Array(xml.utf8) @@ -67,22 +71,38 @@ enum KdbxTestFixture { appendField(&header, 7, encryptionIV) appendField(&header, 8, protectedStreamKey) appendField(&header, 9, streamStartBytes) - appendField(&header, 10, le32(3)) + appendField(&header, 10, le32(innerStreamID)) appendField(&header, 0, [0x0d, 0x0a, 0x0d, 0x0a]) return Data(header + ciphertext) } - /// Encodes `mainKey` the way `c.pwd` stores it: BUILT_IN AES-128-CBC under the - /// hardcoded "Proxy Config Sec" key, with a big-endian IV-length prefix. + // `c.pwd` is YAML around the BUILT_IN blob; the older `pdb.pwd` is the blob alone. static func makeMainKeyFile(mainKey: [UInt8]) -> String { + let base64 = Data(builtInBlob(mainKey: mainKey)).base64EncodedString() + return "isAutoGenerated: true\nvalue: !!binary \(base64)\nencryption: BUILT_IN\n" + } + + // AES-128-CBC under the IDE's "Proxy Config Sec" key, after a big-endian IV length and the IV. + static func builtInBlob(mainKey: [UInt8]) -> [UInt8] { let iv = randomBytes(16) let ciphertext = aesCBCEncrypt(mainKey, key: Array("Proxy Config Sec".utf8), iv: iv) - var blob = beBytes(UInt32(16)) - blob += iv - blob += ciphertext - let base64 = Data(blob).base64EncodedString() - return "isAutoGenerated: true\nvalue: !!binary \(base64)\nencryption: BUILT_IN\n" + return beBytes(UInt32(16)) + iv + ciphertext + } + + static func replacingTransformRounds(in file: Data, with rounds: UInt64) -> Data { + var bytes = [UInt8](file) + var position = 12 + while position + 3 <= bytes.count { + let type = bytes[position] + let length = Int(bytes[position + 1]) | Int(bytes[position + 2]) << 8 + if type == 6 { + bytes.replaceSubrange(position + 3..> 1) ^ 0xedb8_8320 : crc >> 1 + } + return crc + } + private static func crc32(_ data: [UInt8]) -> UInt32 { var crc: UInt32 = 0xffff_ffff for byte in data { - crc ^= UInt32(byte) - for _ in 0..<8 { - crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xedb8_8320 : crc >> 1 - } + crc = crcTable[Int((crc ^ UInt32(byte)) & 0xff)] ^ (crc >> 8) } return crc ^ 0xffff_ffff } diff --git a/apple/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift b/apple/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift index d882ecc967..5a188a8070 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift @@ -251,6 +251,27 @@ struct NavicatImporterTests { #expect(result.credentials(at: 0)?.sshPassword == "This is a test") } + @Test("Decrypts a saved SSH key passphrase, whatever case the export spells it in") + func decryptsKeyPassphrase() throws { + let tunnel = ["SSH": "true", "SSH_Host": "bastion.example.com", "SSH_AuthenMethod": "PUBLICKEY"] + try writeNCX([ + conn(name: "Mixed", extra: tunnel.merging([ + "SSH_SavePassPhrase": "true", "SSH_PassPhrase": "B75D320B6211468D63EB3B67C9E85933" + ]) { _, new in new }), + conn(name: "Lower", extra: tunnel.merging([ + "ssh_savepassphrase": "TRUE", "ssh_passphrase": "B75D320B6211468D63EB3B67C9E85933" + ]) { _, new in new }), + conn(name: "Not saved", extra: tunnel.merging([ + "SSH_SavePassPhrase": "false", "SSH_PassPhrase": "B75D320B6211468D63EB3B67C9E85933" + ]) { _, new in new }) + ]) + let result = try importer.collect(.withPasswords) + #expect(result.credentials(at: 0)?.keyPassphrase == "This is a test") + #expect(result.credentials(at: 0)?.password == nil) + #expect(result.credentials(at: 1)?.keyPassphrase == "This is a test") + #expect(result.credentials(at: 2) == nil) + } + @Test("Maps credentials to the right connection when some have no saved password") func mapsSparseCredentialsByIndex() throws { try writeNCX([ diff --git a/apple/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift b/apple/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift index afcd8c905a..debfc6bc71 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift @@ -98,16 +98,7 @@ struct SequelAceImporterTests { // MARK: - isAvailable - /// `isAvailable()` answers "is Sequel Ace installed", not "is there a favorites file". #1318 - /// moved every foreign-app importer to LaunchServices and deleted this one's file-based - /// override; the case kept asserting the old contract and was quarantined rather than updated. - /// Its sibling below passed on CI only because Sequel Ace is not installed on the runner, which - /// made it assert nothing. Both now drive `resolveAppURL`, so neither depends on what happens to - /// be installed on the machine running them. - /// - /// DBeaver and DataGrip do keep a data-file arm on top of LaunchServices, because a Toolbox or - /// portable install can be invisible to it. A Sequel Ace install is an ordinary `.app`, so it - /// has no such arm and none is expected here. + // Sequel Ace installs as an ordinary .app, so unlike DBeaver and DataGrip it has no data-file arm. @Test("isAvailable returns true when the app is installed") func testIsAvailable_whenAppInstalled_returnsTrue() throws { var imp = importer @@ -138,6 +129,23 @@ struct SequelAceImporterTests { #expect(importer.inventory() == ForeignAppInventory(connections: 3, savedQueries: 0)) } + @Test("inventory counts what collect reads: a connection without a host or numeric id, and a group's children") + func testInventory_countsWhatCollectReads() throws { + var socket = makeConnection(name: "Socket", type: 1) + socket.removeValue(forKey: "host") + socket["id"] = "socket-id" + var group = makeGroup(name: "Group", children: [ + makeConnection(name: "DB1", id: 1), + makeConnection(name: "DB2", id: 2) + ]) + group["host"] = "stray" + try writeFavorites(makeFavoritesRoot(children: [socket, group])) + + let collected = try importer.collect(.connectionsOnly).connections.count + #expect(collected == 3) + #expect(importer.inventory().connections == collected) + } + @Test("inventory is empty when the files are missing") func testInventory_fileMissing_returnsZero() { #expect(importer.inventory() == ForeignAppInventory(connections: 0, savedQueries: 0)) @@ -236,15 +244,47 @@ struct SequelAceImporterTests { #expect(ssl?.clientKeyPath == "/path/to/client-key.pem") } - @Test("collect no SSL when useSSL is 0") - func testCollect_noSSLWhenDisabled() throws { + @Test("collect keeps the TLS libmysqlclient used with SSL off: preferred over TCP") + func testCollect_sslOffOverTCPPrefersTLS() throws { let children: [[String: Any]] = [ makeConnection(name: "No SSL", id: 1, useSSL: 0) ] try writeFavorites(makeFavoritesRoot(children: children)) let result = try importer.collect(.connectionsOnly) - #expect(result.connections[0].sslConfig == nil) + #expect(result.connections[0].sslConfig == ExportableSSLConfig(mode: "Preferred")) + } + + @Test("collect reads SSL off as preferred over SSH, required for cleartext and AWS IAM, and off over a socket") + func testCollect_sslOffFollowsTheClientDefault() throws { + var cleartext = makeConnection(name: "Cleartext", id: 3, useSSL: 0) + cleartext["enableClearTextPlugin"] = 1 + cleartext["sslCACertFileLocation"] = "/unused/ca.pem" + try writeFavorites(makeFavoritesRoot(children: [ + makeConnection(name: "SSH", type: 2, id: 2, sshHost: "bastion.example.com", useSSL: 0), + cleartext, + makeConnection(name: "IAM", type: 3, id: 4, useSSL: 0), + makeConnection(name: "Socket", type: 1, id: 5, useSSL: 0), + makeConnection(name: "Socket SSL", type: 1, id: 6, useSSL: 1, sslCACert: "/certs/ca.pem") + ])) + + let collected = try importer.collect(.connectionsOnly) + let byName = Dictionary(uniqueKeysWithValues: collected.connections.map { ($0.name, $0.sslConfig) }) + #expect(byName["SSH"] == ExportableSSLConfig(mode: "Preferred")) + #expect(byName["Cleartext"] == ExportableSSLConfig(mode: "Required")) + #expect(byName["IAM"] == ExportableSSLConfig(mode: "Required")) + #expect(byName["Socket"] == .some(nil)) + #expect(byName["Socket SSL"]??.mode == "Required") + #expect(byName["Socket SSL"]??.caCertificatePath == "/certs/ca.pem") + + let required = "SSL Required (skip verify) is on because Sequel Ace encrypts this connection by default" + #expect(collected.notes(named: "SSH") == [ + "SSL Preferred is on because Sequel Ace encrypts this connection by default" + ]) + #expect(collected.notes(named: "Cleartext") == [required]) + #expect(collected.notes(named: "IAM") == [required]) + #expect(collected.notes(named: "Socket").isEmpty) + #expect(collected.notes(named: "Socket SSL").isEmpty) } @Test("collect recursive group parsing") @@ -337,7 +377,6 @@ struct SequelAceImporterTests { let result = try importer.collect(.connectionsOnly) let conn = result.connections[0] - // Socket connections (type 1) should not have SSH config #expect(conn.sshConfig == nil) #expect(conn.type == "MySQL") } diff --git a/apple/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift b/apple/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift index 371caad2de..ee69986bc5 100644 --- a/apple/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift +++ b/apple/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift @@ -183,6 +183,17 @@ struct TablePlusImporterTests { #expect(importer.inventory() == ForeignAppInventory(connections: 3, savedQueries: 0)) } + @Test("inventory counts only the connections collect reads") + func testInventory_skipsEntriesWithoutAName() throws { + try writeConnections([ + makeConnection(name: "Valid", id: "valid-1"), + ["Driver": "MySQL", "DatabaseHost": "localhost", "ID": "nameless"] + ]) + + #expect(importer.inventory().connections == 1) + #expect(try importer.collect(.connectionsOnly).connections.count == 1) + } + @Test("inventory is empty when the file is missing") func testInventory_fileMissing_returnsZero() { #expect(importer.inventory() == ForeignAppInventory(connections: 0, savedQueries: 0)) @@ -518,8 +529,8 @@ struct TablePlusImporterTests { #expect(ssl?.clientKeyPath == nil) } - @Test("collect drops an SSL mode past the end of the driver's own popup") - func testCollect_noSSLForTLSModePastDriverVocabulary() throws { + @Test("collect keeps an SSL mode past the end of the driver's own popup as a mode the review warns about") + func testCollect_unreadTLSModePastDriverVocabulary() throws { try writeConnections([ makeConnection(name: "Unknown TLS", id: "ssl-unknown", tlsMode: 99), makeConnection(name: "Past MySQL", driver: "MySQL", id: "ssl-past-mysql", tlsMode: 5), @@ -527,9 +538,8 @@ struct TablePlusImporterTests { ]) let connections = try importer.collect(.connectionsOnly).connections - #expect(connections[0].sslConfig == nil) - #expect(connections[1].sslConfig == nil) - #expect(connections[2].sslConfig == nil) + #expect(connections.map { $0.sslConfig?.mode } == ["99", "5", "2"]) + #expect(connections.allSatisfy { $0.sslConfig?.portableMode == nil }) } @Test("collect keeps Preferred for a driver whose form has no SSL picker") @@ -539,9 +549,13 @@ struct TablePlusImporterTests { makeConnection(name: "Snowflake", driver: "Snowflake", id: "ssl-snowflake", tlsMode: 0) ]) - let connections = try importer.collect(.connectionsOnly).connections - #expect(connections[0].sslConfig?.mode == "Preferred") - #expect(connections[1].sslConfig?.mode == "Preferred") + let collected = try importer.collect(.connectionsOnly) + #expect(collected.connections[0].sslConfig?.mode == "Preferred") + #expect(collected.connections[1].sslConfig?.mode == "Preferred") + #expect(collected.notes(named: "SQL Server") == [ + "SSL Preferred is on because TablePlus encrypts this connection by default" + ]) + #expect(collected.notes(named: "Snowflake").isEmpty) } @Test("collect treats empty TablePlus TLS paths as none") @@ -619,7 +633,6 @@ struct TablePlusImporterTests { @Test("collect skips invalid entries") func testCollect_skipsInvalidEntries() throws { - // Entry without ConnectionName should be skipped let invalidEntry: [String: Any] = [ "Driver": "MySQL", "DatabaseHost": "localhost", @@ -643,7 +656,6 @@ struct TablePlusImporterTests { @Test("collect empty file throws noConnectionsFound") func testCollect_emptyFile_throwsNoConnectionsFound() throws { - // Write an empty array plist try writeConnections([]) #expect(throws: ForeignAppImportError.self) { @@ -653,7 +665,6 @@ struct TablePlusImporterTests { @Test("collect with only invalid entries throws noConnectionsFound") func testCollect_allInvalid_throwsNoConnectionsFound() throws { - // All entries missing ConnectionName let invalid: [String: Any] = ["Driver": "MySQL", "ID": "x"] try writeConnections([invalid, invalid]) @@ -1084,6 +1095,40 @@ struct TablePlusImporterTests { #expect(result.savedQuery(named: "tp_top")?.keyword == "tpok") } + @Test("A keyword file that links to another file is never read") + func testCollect_linkedKeywordFileIsNotRead() throws { + try writeFavoriteFixture() + let secret = tempDir.appendingPathComponent("secret.txt") + try ForeignFixture.write("token", to: secret) + try FileManager.default.createSymbolicLink( + at: favoriteRoot.appendingPathComponent("tp_top.sql.tag"), + withDestinationURL: secret + ) + + let result = try importer.collect(.withSavedQueries) + + #expect(result.savedQuery(named: "tp_top")?.keyword == nil) + #expect(result.savedQuery(named: "tp_root_kw")?.keyword == "kwone") + } + + @Test("A keyword file that is a pipe is never opened") + func testKeyword_pipeIsNeverOpened() throws { + let query = favoriteRoot.appendingPathComponent("piped.sql") + try ForeignFixture.write("SELECT 1;", to: query) + let pipePath = favoriteRoot.appendingPathComponent("piped.sql.tag").path + try #require(mkfifo(pipePath, 0o600) == 0) + // Opening the pipe would wait for a writer; this one turns a regression into a failure, not a hang. + DispatchQueue.global().asyncAfter(deadline: .now() + 5) { + let descriptor = open(pipePath, O_WRONLY | O_NONBLOCK) + guard descriptor >= 0 else { return } + _ = "leaked".withCString { write(descriptor, $0, 6) } + close(descriptor) + } + let entry = ForeignSQLFileTree.Entry(url: query, folderPath: [], fileName: "piped.sql", byteCount: 9) + + #expect(TablePlusFavoriteReader.keyword(for: entry) == nil) + } + @Test("collect reads no favorites unless saved queries are requested") func testCollect_withoutSavedQueries_readsNoFavorites() throws { try writeFavoriteFixture() diff --git a/apple/changelog.d/4015.fixed.md b/apple/changelog.d/4015.fixed.md new file mode 100644 index 0000000000..bf9ceea367 --- /dev/null +++ b/apple/changelog.d/4015.fixed.md @@ -0,0 +1 @@ +Importing from another app now keeps the TLS, key passphrases and extra connection files that app saved. diff --git a/core/crates/foreign-import/src/apps/beekeeper/mod.rs b/core/crates/foreign-import/src/apps/beekeeper/mod.rs index 8d26a8b5ca..ce33330788 100644 --- a/core/crates/foreign-import/src/apps/beekeeper/mod.rs +++ b/core/crates/foreign-import/src/apps/beekeeper/mod.rs @@ -119,7 +119,12 @@ fn record( .port .and_then(|port| u16::try_from(port).ok()) .unwrap_or_else(|| engine::default_port(&engine)); - let ssl = row.ssl.then(|| ssl(row, &engine)); + let mut additional_fields = BTreeMap::new(); + let ssl = if row.ssl { + Some(ssl(row, &engine)) + } else { + ssl_with_ssl_off(row, &engine, &mut additional_fields) + }; let settings = ForeignConnection { name: if row.name.is_empty() { "Untitled".to_owned() @@ -138,7 +143,7 @@ fn record( ssh: row.ssh_enabled.then(|| ssh(row)), ssl, color: map_color(row.label_color.as_deref()), - additional_fields: BTreeMap::new(), + additional_fields, }; ForeignConnectionRecord { source_id: Some(row.id.to_string()), @@ -219,7 +224,7 @@ fn ssl(row: &SavedConnectionRow, engine: &str) -> ForeignSsl { } } -// What each Beekeeper connector hands its driver (`lib/db/clients/*/*Connector.ts`). The saved +// What each Beekeeper client hands its driver (`apps/studio/src/lib/db/clients/*.ts`). The saved // `trustServerCertificate` is SQL Server's alone, and TLS on replaces it there. fn ssl_mode(row: &SavedConnectionRow, engine: &str) -> ForeignSslMode { let file = |path: &Option| path.as_deref().is_some_and(|path| !path.is_empty()); @@ -243,6 +248,30 @@ fn ssl_mode(row: &SavedConnectionRow, engine: &str) -> ForeignSslMode { } } +// With SSL off the SQL Server client hands mssql only `trustServerCertificate`, and mssql fills in +// `encrypt: true`, so tedious encrypts and checks the certificate unless told to trust it. The other +// clients give their driver no TLS at all. +fn ssl_with_ssl_off( + row: &SavedConnectionRow, + engine: &str, + fields: &mut BTreeMap, +) -> Option { + if engine != "SQL Server" { + return None; + } + fields.insert(crate::record::TLS_FROM_DRIVER_DEFAULT.to_owned(), "true".to_owned()); + Some(ForeignSsl { + mode: if row.trust_server_certificate { + ForeignSslMode::Required + } else { + ForeignSslMode::VerifyIdentity + }, + ca_certificate_path: None, + client_certificate_path: None, + client_key_path: None, + }) +} + fn map_color(raw: Option<&str>) -> Option { match raw?.to_lowercase().as_str() { "red" => Some(ForeignColor::Red), @@ -287,6 +316,7 @@ struct SavedConnectionRow { ssl_cert_file: Option, ssl_key_file: Option, ssl_reject_unauthorized: bool, + trust_server_certificate: bool, ssh_enabled: bool, ssh_host: Option, ssh_port: Option, @@ -310,7 +340,7 @@ const SAVED_CONNECTIONS_SQL: &str = " ssl, sslCaFile, sslCertFile, sslKeyFile, sslRejectUnauthorized, sshEnabled, sshHost, sshPort, sshUsername, sshMode, sshKeyfile, sshKeyfilePassword, sshPassword, sshBastionHost, sshBastionHostPort, sshBastionUsername, sshBastionMode, sshBastionKeyfile, - labelColor, connectionFolderId + labelColor, connectionFolderId, trustServerCertificate FROM saved_connection WHERE workspaceId = -1 ORDER BY id"; @@ -357,6 +387,7 @@ fn saved_connection(row: &Row<'_>) -> SavedConnectionRow { ssh_bastion_keyfile: row.text(25), label_color: row.text(26), connection_folder_id: row.optional_int64(27), + trust_server_certificate: flag(28), } } diff --git a/core/crates/foreign-import/src/apps/datagrip/data_sources.rs b/core/crates/foreign-import/src/apps/datagrip/data_sources.rs index 0f662aec50..62e7e6a967 100644 --- a/core/crates/foreign-import/src/apps/datagrip/data_sources.rs +++ b/core/crates/foreign-import/src/apps/datagrip/data_sources.rs @@ -6,6 +6,7 @@ use std::path::Path; use roxmltree::Node; +use crate::apps::jdbc; use crate::apps::jetbrains::{expand, xml}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -18,6 +19,8 @@ pub(crate) struct DataSource { pub group_name: Option, pub ssh: Option, pub ssl: Option, + // An Advanced-tab driver property sets TLS, which the driver reads its own way. + pub tls_property: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -30,6 +33,8 @@ pub(crate) struct Fragment { pub group_name: Option, pub ssh: Section, pub ssl: Section, + // None when the fragment has no `driver-properties`. + pub tls_property: Option, } // The local file can turn off a section the shared file turns on; a file without the section leaves it. @@ -88,6 +93,7 @@ impl Fragment { take(&mut self.jdbc_url, other.jdbc_url); take(&mut self.username, other.username); take(&mut self.group_name, other.group_name); + take(&mut self.tls_property, other.tls_property); if other.ssh != Section::Absent { self.ssh = other.ssh; } @@ -108,6 +114,7 @@ impl Fragment { group_name: self.group_name.clone(), ssh: self.ssh.enabled(), ssl: self.ssl.enabled(), + tls_property: self.tls_property.unwrap_or(false), }) } } @@ -154,6 +161,13 @@ fn parse_fragment(element: Node<'_, '_>, uuid: &str, home: &Path) -> Fragment { group_name: non_empty(element.attribute("group-name").map(str::to_owned)), ssh: parse_ssh_reference(element), ssl: parse_ssl_properties(element, home), + tls_property: xml::child(element, "driver-properties").map(|properties| { + properties + .children() + .filter(|property| property.is_element() && property.has_tag_name("property")) + .filter_map(|property| property.attribute("name")) + .any(|name| jdbc::is_tls_parameter(&name.trim().to_lowercase())) + }), } } diff --git a/core/crates/foreign-import/src/apps/datagrip/mod.rs b/core/crates/foreign-import/src/apps/datagrip/mod.rs index 1293f6a02e..778abc03fe 100644 --- a/core/crates/foreign-import/src/apps/datagrip/mod.rs +++ b/core/crates/foreign-import/src/apps/datagrip/mod.rs @@ -308,7 +308,10 @@ fn make_connection(source: &DataSource, ssh_configs: &HashMap // The macOS port read TLS from the XML alone, so a URL that asks for it imported in the clear. let ssl = match source.ssl.as_ref() { Some(ssl) => Some(make_ssl(ssl)), - None => jdbc::url_ssl_or_mark(&source.jdbc_url, &mut additional_fields), + None => jdbc::url_ssl_or_mark(&source.jdbc_url, &mut additional_fields).or_else(|| { + let mode = driver_default_mode(&engine, &subprotocol)?; + jdbc::driver_default_ssl(&source.jdbc_url, source.tls_property, mode, &mut additional_fields) + }), }; ForeignConnection { name: source.name.clone(), @@ -385,6 +388,17 @@ fn make_ssl(ssl: &SslProperties) -> ForeignSsl { } } +// With "Use SSL" off DataGrip passes no TLS property, so the driver's own default applies: pgjdbc +// prefers TLS, Connector/J too, and mssql-jdbc 10.1 and later encrypts and checks the certificate. +// MariaDB Connector/J and jTDS start without TLS. +fn driver_default_mode(engine: &str, subprotocol: &str) -> Option { + match (engine, subprotocol.to_lowercase().as_str()) { + ("PostgreSQL", "postgresql") | ("MySQL", "mysql") => Some(ForeignSslMode::Preferred), + ("SQL Server", "sqlserver") => Some(ForeignSslMode::VerifyIdentity), + _ => None, + } +} + fn is_mongo(driver_ref: Option<&str>, jdbc_url: Option<&str>) -> bool { let url = jdbc_url.unwrap_or_default(); if url.to_lowercase().starts_with("mongodb") { @@ -459,6 +473,7 @@ mod tests { group_name: None, ssh: None, ssl: None, + tls_property: false, } } @@ -473,8 +488,17 @@ mod tests { &source("postgresql", "jdbc:postgresql://db.example.com/app"), &HashMap::new(), ); - assert_eq!(plain.ssl, None); - assert!(plain.additional_fields.is_empty()); + assert_eq!(plain.ssl.map(|ssl| ssl.mode), Some(ForeignSslMode::Preferred)); + assert_eq!( + plain.additional_fields.keys().collect::>(), + [crate::record::TLS_FROM_DRIVER_DEFAULT] + ); + let disabled = make_connection( + &source("postgresql", "jdbc:postgresql://db.example.com/app?sslmode=disable"), + &HashMap::new(), + ); + assert_eq!(disabled.ssl, None); + assert!(disabled.additional_fields.is_empty()); let unread = make_connection( &source("postgresql", "jdbc:postgresql://db.example.com/app?ssl=true"), &HashMap::new(), diff --git a/core/crates/foreign-import/src/apps/dbeaver/mod.rs b/core/crates/foreign-import/src/apps/dbeaver/mod.rs index 244de4d551..234ede609f 100644 --- a/core/crates/foreign-import/src/apps/dbeaver/mod.rs +++ b/core/crates/foreign-import/src/apps/dbeaver/mod.rs @@ -265,7 +265,16 @@ fn settings( additional_fields.insert(crate::TLS_SETTINGS_UNREAD.to_owned(), "true".to_owned()); } // A URL-only connection keeps the TLS its URL asks for, which the macOS port dropped. - text("url").and_then(|url| jdbc::url_ssl_or_mark(url, &mut additional_fields)) + let from_url = text("url").and_then(|url| jdbc::url_ssl_or_mark(url, &mut additional_fields)); + match driver_default_mode(connection) { + Some(mode) if from_url.is_none() && matches!(tls, HandlerTls::Absent) => jdbc::driver_default_ssl( + text("url").unwrap_or_default(), + property_sets_tls(config), + mode, + &mut additional_fields, + ), + _ => from_url, + } } }; ForeignConnection { @@ -284,6 +293,38 @@ fn settings( } } +// The PostgreSQL drivers DBeaver ships as pgjdbc (`org.postgresql.Driver` in its plugin.xml). +const PGJDBC_DRIVERS: &[&str] = &[ + "postgres-jdbc", + "postgresql", + "postgres-greenplum-jdbc", + "postgres-timescale-jdbc", + "postgres-yugabytedb-jdbc", + "postgres-cockroach-jdbc", + "postgres-materialize-jdbc", + "postgres-gcloud-jdbc", + "postgres-cratedb-jdbc", + "postgres-risingwave-jdbc", +]; + +// With its TLS handler off DBeaver hands pgjdbc `ssl=false` and no `sslmode`, which pgjdbc reads +// as `prefer`. Its MySQL and SQL Server connections turn TLS off outright. +fn driver_default_mode(connection: &Value) -> Option { + let provider = connection.get("provider").and_then(Value::as_str)?; + let driver = connection.get("driver").and_then(Value::as_str)?; + (provider.eq_ignore_ascii_case("postgresql") && PGJDBC_DRIVERS.contains(&driver)) + .then_some(ForeignSslMode::Preferred) +} + +// The driver properties the user set, which DBeaver passes on as they are. +fn property_sets_tls(config: &Value) -> bool { + config + .get("properties") + .cloned() + .and_then(object) + .is_some_and(|properties| properties.keys().any(|key| jdbc::is_tls_parameter(&key.to_lowercase()))) +} + fn map_provider(provider: &str) -> String { let known = match provider.to_lowercase().as_str() { "mysql" => "MySQL", diff --git a/core/crates/foreign-import/src/apps/jdbc.rs b/core/crates/foreign-import/src/apps/jdbc.rs index e4b5f36d87..3adda0da88 100644 --- a/core/crates/foreign-import/src/apps/jdbc.rs +++ b/core/crates/foreign-import/src/apps/jdbc.rs @@ -91,15 +91,9 @@ pub(crate) enum UrlTls { pub(crate) fn url_tls(url: &str) -> UrlTls { let parameters = url_parameters(url); - let other_switch = parameters.iter().any(|(key, _)| { - key != "sslmode" - && (key.starts_with("ssl") - || key.starts_with("tls") - || matches!( - key.as_str(), - "usessl" | "requiressl" | "encrypt" | "trustservercertificate" | "verifyservercertificate" - )) - }); + let other_switch = parameters + .iter() + .any(|(key, _)| key != "sslmode" && is_tls_parameter(key)); if other_switch { return UrlTls::Unread; } @@ -110,6 +104,40 @@ pub(crate) fn url_tls(url: &str) -> UrlTls { } } +// A driver parameter or property, by its lowercased name, that sets TLS in one driver or another. +pub(crate) fn is_tls_parameter(key: &str) -> bool { + key.starts_with("ssl") + || key.starts_with("tls") + || matches!( + key, + "usessl" | "requiressl" | "encrypt" | "trustservercertificate" | "verifyservercertificate" + ) +} + +// The TLS a driver uses by itself when neither the app, the URL nor a saved driver property sets +// any. A property that sets TLS is not read, so it marks TLS unread rather than taking the default. +pub(crate) fn driver_default_ssl( + url: &str, + property_sets_tls: bool, + mode: ForeignSslMode, + fields: &mut std::collections::BTreeMap, +) -> Option { + if url_tls(url) != UrlTls::None { + return None; + } + if property_sets_tls { + fields.insert(crate::TLS_SETTINGS_UNREAD.to_owned(), "true".to_owned()); + return None; + } + fields.insert(crate::record::TLS_FROM_DRIVER_DEFAULT.to_owned(), "true".to_owned()); + Some(ForeignSsl { + mode, + ca_certificate_path: None, + client_certificate_path: None, + client_key_path: None, + }) +} + // `jdbc:clickhouse:https://` and the like: a scheme that is TLS by itself, verified by default. fn secure_scheme(url: &str) -> bool { url.split_once("://").is_some_and(|(scheme, _)| { @@ -567,6 +595,32 @@ mod tests { assert_eq!(url_ssl("jdbc:postgresql://db/app?sslmode=disable"), None); } + #[test] + fn a_driver_default_applies_only_when_neither_url_nor_property_sets_tls() { + let default = |url: &str, property_sets_tls: bool| { + let mut fields = std::collections::BTreeMap::new(); + let ssl = driver_default_ssl(url, property_sets_tls, ForeignSslMode::Preferred, &mut fields); + (ssl.map(|ssl| ssl.mode), fields.into_keys().collect::>()) + }; + assert_eq!( + default("jdbc:postgresql://db/app", false), + ( + Some(ForeignSslMode::Preferred), + vec![crate::record::TLS_FROM_DRIVER_DEFAULT.to_owned()] + ) + ); + assert_eq!( + default("jdbc:postgresql://db/app", true), + (None, vec![crate::TLS_SETTINGS_UNREAD.to_owned()]) + ); + assert_eq!( + default("jdbc:postgresql://db/app?sslmode=disable", false), + (None, vec![]) + ); + assert_eq!(default("jdbc:postgresql://db/app?ssl=true", true), (None, vec![])); + assert!(is_tls_parameter("usessl") && is_tls_parameter("sslrootcert") && !is_tls_parameter("user")); + } + #[test] fn a_url_names_its_user_but_never_its_password() { let endpoint = parse("mongodb://alice%40corp:s3cret@db.example/shop", "").expect("an endpoint"); diff --git a/core/crates/foreign-import/src/apps/sequel_ace/mod.rs b/core/crates/foreign-import/src/apps/sequel_ace/mod.rs index 5e06ab59a2..0a85c2cf40 100644 --- a/core/crates/foreign-import/src/apps/sequel_ace/mod.rs +++ b/core/crates/foreign-import/src/apps/sequel_ace/mod.rs @@ -19,7 +19,9 @@ use crate::{ }; const DEFAULT_PORT: u16 = 3306; +const SOCKET_TYPE: i64 = 1; const SSH_TUNNEL_TYPE: i64 = 2; +const AWS_IAM_TYPE: i64 = 3; pub(crate) struct SequelAce { favorites: PathBuf, @@ -169,6 +171,8 @@ fn parse_children( fn connection(entry: &Dictionary) -> ForeignConnection { let connection_type = integer(entry.get("type")).unwrap_or(0); + let mut additional_fields = BTreeMap::new(); + let ssl = ssl(entry, connection_type, &mut additional_fields); ForeignConnection { name: text(entry, "name").unwrap_or("Untitled").to_owned(), host: text(entry, "host").unwrap_or("localhost").to_owned(), @@ -177,9 +181,9 @@ fn connection(entry: &Dictionary) -> ForeignConnection { username: text(entry, "user").unwrap_or_default().to_owned(), engine: "MySQL".to_owned(), ssh: ssh(entry, connection_type), - ssl: ssl(entry), + ssl, color: color(integer(entry.get("colorIndex")).unwrap_or(-1)), - additional_fields: BTreeMap::new(), + additional_fields, } } @@ -206,16 +210,29 @@ fn ssh(entry: &Dictionary, connection_type: i64) -> Option { }) } -fn ssl(entry: &Dictionary) -> Option { - if integer(entry.get("useSSL")).unwrap_or(0) == 0 { +// With SSL off Sequel Ace still asks libmysqlclient to prefer TLS, and requires it for the cleartext +// plugin and for AWS IAM, which also turns SSL on; over a socket the client leaves TLS off. +fn ssl(entry: &Dictionary, connection_type: i64, fields: &mut BTreeMap) -> Option { + let flag = |key: &str| integer(entry.get(key)).unwrap_or(0) != 0; + let path = |key: &str| text(entry, key).map(str::to_owned); + let ssl_on = flag("useSSL"); + if !ssl_on && connection_type == SOCKET_TYPE { return None; } - let path = |key: &str| text(entry, key).map(str::to_owned); + let required = ssl_on || connection_type == AWS_IAM_TYPE; + if !ssl_on { + fields.insert(crate::record::TLS_FROM_DRIVER_DEFAULT.to_owned(), "true".to_owned()); + } + let mode = if required || flag("enableClearTextPlugin") { + ForeignSslMode::Required + } else { + ForeignSslMode::Preferred + }; Some(ForeignSsl { - mode: ForeignSslMode::Required, - ca_certificate_path: path("sslCACertFileLocation"), - client_certificate_path: path("sslCertificateFileLocation"), - client_key_path: path("sslKeyFileLocation"), + mode, + ca_certificate_path: required.then(|| path("sslCACertFileLocation")).flatten(), + client_certificate_path: required.then(|| path("sslCertificateFileLocation")).flatten(), + client_key_path: required.then(|| path("sslKeyFileLocation")).flatten(), }) } diff --git a/core/crates/foreign-import/src/apps/tableplus/connection.rs b/core/crates/foreign-import/src/apps/tableplus/connection.rs index 9fb7fd2a39..56be447170 100644 --- a/core/crates/foreign-import/src/apps/tableplus/connection.rs +++ b/core/crates/foreign-import/src/apps/tableplus/connection.rs @@ -217,6 +217,11 @@ impl Mapping<'_> { if entry.tls_unread || (entry.tls_mode.is_some() && ssl.is_none()) { additional_fields.insert(crate::TLS_SETTINGS_UNREAD.to_owned(), "true".to_owned()); } + // The macOS SQL Server form has no TLS picker, and the app's FreeTDS asks for TLS whenever the + // server offers it. + if self.dialect == Dialect::MacOs && driver == "MicrosoftSQLServer" && ssl.is_some() { + additional_fields.insert(crate::record::TLS_FROM_DRIVER_DEFAULT.to_owned(), "true".to_owned()); + } Some(ForeignConnection { name, diff --git a/core/crates/foreign-import/src/record.rs b/core/crates/foreign-import/src/record.rs index 9e14750349..f9165b2470 100644 --- a/core/crates/foreign-import/src/record.rs +++ b/core/crates/foreign-import/src/record.rs @@ -6,6 +6,10 @@ use secrecy::SecretString; // says so and leaves the connection unchecked instead of importing it with TLS off. pub const TLS_SETTINGS_UNREAD: &str = "tlsSettingsUnread"; +// Set in `additional_fields` when the app's own SSL setting reads off but it or its driver encrypts +// anyway, so the review says where the imported TLS came from. +pub const TLS_FROM_DRIVER_DEFAULT: &str = "tlsFromDriverDefault"; + // The fields follow the macOS app's exported connection, so every platform maps the same record. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ForeignConnection { diff --git a/core/crates/foreign-import/src/review/mapping.rs b/core/crates/foreign-import/src/review/mapping.rs index 62ea972051..4bf00ecab3 100644 --- a/core/crates/foreign-import/src/review/mapping.rs +++ b/core/crates/foreign-import/src/review/mapping.rs @@ -67,6 +67,13 @@ pub(super) fn map( ) -> Mapped { let caps = driver.capabilities(); let mut notes = Vec::new(); + if settings + .additional_fields + .contains_key(crate::record::TLS_FROM_DRIVER_DEFAULT) + && let Some(ssl) = &settings.ssl + { + notes.push(RowNote::TlsFromDriverDefault { mode: ssl.mode }); + } let file_based = caps.is_file_based(); let (host, port, database, username, tls, auth_mode, ssh) = if file_based { if settings.ssh.is_some() { @@ -112,6 +119,7 @@ pub(super) fn map( (seed_list, MONGO_USE_SRV), (auth_database.is_some(), MONGO_AUTH_SOURCE), (true, crate::TLS_SETTINGS_UNREAD), + (true, crate::record::TLS_FROM_DRIVER_DEFAULT), ] .into_iter() .filter_map(|(kept, key)| kept.then_some(key)) diff --git a/core/crates/foreign-import/src/review/notes.rs b/core/crates/foreign-import/src/review/notes.rs index dd3dae7e0c..9842d7826d 100644 --- a/core/crates/foreign-import/src/review/notes.rs +++ b/core/crates/foreign-import/src/review/notes.rs @@ -16,6 +16,8 @@ pub enum RowNote { TlsModeChanged { from: ForeignSslMode, to: TlsMode }, // The app saved TLS settings the importer cannot read, so the row starts unchecked. TlsSettingsNotRead, + // The app's SSL setting reads off, but it or its driver encrypted the connection anyway. + TlsFromDriverDefault { mode: ForeignSslMode }, // The engine has no TLS, so the connection opens in the clear. TlsNotKept { from: ForeignSslMode }, CaFileNotKept { path: String, reason: TlsFileReason }, diff --git a/core/crates/foreign-import/tests/beekeeper.rs b/core/crates/foreign-import/tests/beekeeper.rs index ec41d617d6..121d5de134 100644 --- a/core/crates/foreign-import/tests/beekeeper.rs +++ b/core/crates/foreign-import/tests/beekeeper.rs @@ -530,6 +530,49 @@ fn tls_verification_follows_what_each_engine_s_connector_does() { } } +#[test] +fn sql_server_with_ssl_off_imports_the_tls_mssql_turns_on_by_default() { + let (_home, importer) = mac_home(&["connections.sql", "queries.sql", "tls_off_connections.sql"]); + + let collected = collect(&importer, &CONNECTIONS_ONLY); + + let cases = [ + ("SQL Server trusting", Some(ForeignSslMode::Required)), + ("SQL Server checking", Some(ForeignSslMode::VerifyIdentity)), + ("PG plain", None), + ("MySQL plain", None), + ]; + for (name, mode) in cases { + let settings = &collected + .connections + .iter() + .find(|connection| connection.settings.name == name) + .expect("a connection") + .settings; + assert_eq!(settings.ssl.as_ref().map(|ssl| ssl.mode), mode, "{name}"); + assert_eq!( + settings + .additional_fields + .get("tlsFromDriverDefault") + .map(String::as_str), + mode.map(|_| "true"), + "{name}" + ); + if let Some(ssl) = &settings.ssl { + // SSL off hands the driver no certificate file. + assert_eq!( + ( + &ssl.ca_certificate_path, + &ssl.client_certificate_path, + &ssl.client_key_path + ), + (&None, &None, &None), + "{name}" + ); + } + } +} + #[test] fn a_cancelled_import_stops() { let (_home, importer) = mac_home(FULL_DATABASE); diff --git a/core/crates/foreign-import/tests/datagrip.rs b/core/crates/foreign-import/tests/datagrip.rs index 5915477bd2..bf5bcdf17a 100644 --- a/core/crates/foreign-import/tests/datagrip.rs +++ b/core/crates/foreign-import/tests/datagrip.rs @@ -243,10 +243,13 @@ fn disabled_ssh_properties_give_no_tunnel() { } #[test] -fn ssh_and_ssl_the_local_file_disables_stay_off_though_the_shared_file_enables_them() { +fn ssh_and_ssl_the_local_file_disables_drop_what_the_shared_file_enables() { let import = collect(&mac("local-disables", Arc::default()), request(false, false)); let off = &connection(&import, "turned_off").settings; - assert_eq!((&off.ssh, &off.ssl), (&None, &None)); + assert_eq!(off.ssh, None); + // With "Use SSL" off pgjdbc's own default applies, not the shared file's REQUIRE. + assert_eq!(off.ssl.as_ref().map(|ssl| ssl.mode), Some(ForeignSslMode::Preferred)); + assert!(off.additional_fields.contains_key("tlsFromDriverDefault")); let on = &connection(&import, "left_on").settings; assert_eq!( on.ssh.as_ref().map(|ssh| ssh.host.as_str()), @@ -255,6 +258,37 @@ fn ssh_and_ssl_the_local_file_disables_stay_off_though_the_shared_file_enables_t assert_eq!(on.ssl.as_ref().map(|ssl| ssl.mode), Some(ForeignSslMode::Required)); } +#[test] +fn with_use_ssl_off_each_jdbc_driver_s_own_tls_default_is_imported() { + let import = collect( + &plain(&fixture("tls-defaults", "linux"), ForeignPlatform::Linux), + request(false, false), + ); + let defaulted = Some("tlsFromDriverDefault"); + let unread = Some("tlsSettingsUnread"); + let cases = [ + ("pg_default", Some(ForeignSslMode::Preferred), defaulted), + ("pg_ssl_off", Some(ForeignSslMode::Preferred), defaulted), + ("mysql_default", Some(ForeignSslMode::Preferred), defaulted), + ("mssql_default", Some(ForeignSslMode::VerifyIdentity), defaulted), + ("mssql_trusting_url", None, unread), + ("pg_property_off", None, unread), + ("mariadb_default", None, None), + ("jtds_default", None, None), + ]; + for (name, mode, marker) in cases { + let settings = &connection(&import, name).settings; + assert_eq!(settings.ssl.as_ref().map(|ssl| ssl.mode), mode, "{name}"); + let markers: Vec<&str> = settings + .additional_fields + .keys() + .map(String::as_str) + .filter(|key| ["tlsFromDriverDefault", "tlsSettingsUnread"].contains(key)) + .collect(); + assert_eq!(markers, marker.into_iter().collect::>(), "{name}"); + } +} + #[test] fn an_ssh_password_is_imported_from_c_kdbx() { let import = collect(&mac("connections", Arc::default()), request(true, false)); diff --git a/core/crates/foreign-import/tests/dbeaver.rs b/core/crates/foreign-import/tests/dbeaver.rs index c3a5471852..8e91a0e82b 100644 --- a/core/crates/foreign-import/tests/dbeaver.rs +++ b/core/crates/foreign-import/tests/dbeaver.rs @@ -529,6 +529,42 @@ fn a_tls_handler_whose_mode_is_not_read_marks_the_connection_instead_of_turning_ } } +#[test] +fn postgres_with_its_tls_handler_off_imports_the_tls_pgjdbc_prefers() { + let import = collect( + &importer(ForeignPlatform::Linux, &fixture("linux-tls-defaults")), + CONNECTIONS_ONLY, + ); + let cases = [ + ( + "PG no handler", + Some(ForeignSslMode::Preferred), + Some("tlsFromDriverDefault"), + ), + ( + "PG handler off", + Some(ForeignSslMode::Preferred), + Some("tlsFromDriverDefault"), + ), + ("PG driver property", None, Some(TLS_SETTINGS_UNREAD)), + ("PG URL off", None, None), + ("Redshift driver", None, None), + ("MySQL no handler", None, None), + ("SQL Server no handler", None, None), + ]; + for (name, mode, marker) in cases { + let settings = &connection(&import, name).settings; + assert_eq!(settings.ssl.as_ref().map(|ssl| ssl.mode), mode, "{name}"); + let markers: Vec<&str> = settings + .additional_fields + .keys() + .map(String::as_str) + .filter(|key| [TLS_SETTINGS_UNREAD, "tlsFromDriverDefault"].contains(key)) + .collect(); + assert_eq!(markers, marker.into_iter().collect::>(), "{name}"); + } +} + #[test] fn a_tls_handler_set_to_disable_turns_tls_off() { let import = collect(&macos(), CONNECTIONS_ONLY); diff --git a/core/crates/foreign-import/tests/fixtures/beekeeper/tls_off_connections.sql b/core/crates/foreign-import/tests/fixtures/beekeeper/tls_off_connections.sql new file mode 100644 index 0000000000..8362b2ae58 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/beekeeper/tls_off_connections.sql @@ -0,0 +1,8 @@ +-- SSL off. The SQL Server form still saves its own "Trust Server Certificate?" box, and certificate files +-- left from an earlier SSL setup stay in the row. +INSERT INTO saved_connection (id, name, connectionType, host, ssl, sslCaFile, sslCertFile, sslKeyFile, + sslRejectUnauthorized, trustServerCertificate) VALUES + (21, 'SQL Server trusting', 'sqlserver', 'ms', 0, NULL, NULL, NULL, 1, 1), + (22, 'SQL Server checking', 'sqlserver', 'ms', 0, '/certs/ca.pem', '/certs/c.pem', '/certs/c.key', 1, 0), + (23, 'PG plain', 'postgresql', 'pg', 0, NULL, NULL, NULL, 1, 1), + (24, 'MySQL plain', 'mysql', 'my', 0, NULL, NULL, NULL, 1, 1); diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/tls-defaults/linux/.config/JetBrains/DataGrip2026.2/options/dataSources.local.xml b/core/crates/foreign-import/tests/fixtures/datagrip/tls-defaults/linux/.config/JetBrains/DataGrip2026.2/options/dataSources.local.xml new file mode 100644 index 0000000000..ab24eaec7b --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/tls-defaults/linux/.config/JetBrains/DataGrip2026.2/options/dataSources.local.xml @@ -0,0 +1,14 @@ + + + + + master_key + app + + + false + VERIFY_FULL + + + + diff --git a/core/crates/foreign-import/tests/fixtures/datagrip/tls-defaults/linux/.config/JetBrains/DataGrip2026.2/options/dataSources.xml b/core/crates/foreign-import/tests/fixtures/datagrip/tls-defaults/linux/.config/JetBrains/DataGrip2026.2/options/dataSources.xml new file mode 100644 index 0000000000..2911b2224a --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/datagrip/tls-defaults/linux/.config/JetBrains/DataGrip2026.2/options/dataSources.xml @@ -0,0 +1,71 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://pg.example.com:5432/app + + + + $ProjectFileDir$ + + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://my.example.com:3306/shop + + + + + $ProjectFileDir$ + + + sqlserver.ms + true + com.microsoft.sqlserver.jdbc.SQLServerDriver + jdbc:sqlserver://ms.example.com:1433;database=sales + $ProjectFileDir$ + + + sqlserver.ms + true + com.microsoft.sqlserver.jdbc.SQLServerDriver + jdbc:sqlserver://ms.example.com;database=sales;trustServerCertificate=true + $ProjectFileDir$ + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://pg.example.com:5432/app + + + + $ProjectFileDir$ + + + mariadb + true + org.mariadb.jdbc.Driver + jdbc:mariadb://maria.example.com:3306/shop + $ProjectFileDir$ + + + sqlserver.jtds + true + net.sourceforge.jtds.jdbc.Driver + jdbc:jtds:sqlserver://ms.example.com:1433/sales + $ProjectFileDir$ + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://pg.example.com:5432/app + $ProjectFileDir$ + + + diff --git a/core/crates/foreign-import/tests/fixtures/dbeaver/linux-tls-defaults/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-tls-defaults/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json new file mode 100644 index 0000000000..33aa45dbc4 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/dbeaver/linux-tls-defaults/.local/share/DBeaverData/workspace6/General/.dbeaver/data-sources.json @@ -0,0 +1,113 @@ +{ + "folders": {}, + "connections": { + "postgres-jdbc-1a2b3c": { + "provider": "postgresql", + "driver": "postgres-jdbc", + "name": "PG no handler", + "save-password": true, + "configuration": { + "host": "pg.example.com", + "port": "5432", + "database": "app", + "url": "jdbc:postgresql://pg.example.com:5432/app", + "configurationType": "MANUAL", + "type": "dev", + "auth-model": "native" + } + }, + "postgres-jdbc-4d5e6f": { + "provider": "postgresql", + "driver": "postgres-jdbc", + "name": "PG handler off", + "configuration": { + "host": "pg.example.com", + "port": "5432", + "database": "app", + "url": "jdbc:postgresql://pg.example.com:5432/app", + "configurationType": "MANUAL", + "type": "dev", + "auth-model": "native", + "handlers": { + "postgre_ssl": { + "type": "CONFIG", + "enabled": false, + "save-password": false, + "properties": { + "sslMode": "verify-full" + } + } + } + } + }, + "postgres-jdbc-7a8b9c": { + "provider": "postgresql", + "driver": "postgres-jdbc", + "name": "PG driver property", + "configuration": { + "host": "pg.example.com", + "port": "5432", + "database": "app", + "url": "jdbc:postgresql://pg.example.com:5432/app", + "configurationType": "MANUAL", + "type": "dev", + "auth-model": "native", + "properties": { + "sslmode": "disable" + } + } + }, + "postgres-jdbc-0d1e2f": { + "provider": "postgresql", + "driver": "postgres-jdbc", + "name": "PG URL off", + "configuration": { + "url": "jdbc:postgresql://pg.example.com:5432/app?sslmode=disable", + "configurationType": "URL", + "type": "dev", + "auth-model": "native" + } + }, + "postgres-redshift-jdbc-3a4b5c": { + "provider": "postgresql", + "driver": "postgres-redshift-jdbc", + "name": "Redshift driver", + "configuration": { + "host": "cluster.example.com", + "port": "5439", + "database": "dev", + "url": "jdbc:redshift://cluster.example.com:5439/dev", + "configurationType": "MANUAL", + "type": "dev", + "auth-model": "native" + } + }, + "mysql8-6d7e8f": { + "provider": "mysql", + "driver": "mysql8", + "name": "MySQL no handler", + "configuration": { + "host": "my.example.com", + "port": "3306", + "url": "jdbc:mysql://my.example.com:3306/", + "configurationType": "MANUAL", + "type": "dev", + "auth-model": "native" + } + }, + "microsoft-9a0b1c": { + "provider": "sqlserver", + "driver": "microsoft", + "name": "SQL Server no handler", + "configuration": { + "host": "ms.example.com", + "port": "1433", + "database": "master", + "url": "jdbc:sqlserver://ms.example.com:1433;databaseName=master", + "configurationType": "MANUAL", + "type": "dev", + "auth-model": "native" + } + } + } +} diff --git a/core/crates/foreign-import/tests/fixtures/sequel_ace/tls-off/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist b/core/crates/foreign-import/tests/fixtures/sequel_ace/tls-off/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist new file mode 100644 index 0000000000..ec43fbfb08 --- /dev/null +++ b/core/crates/foreign-import/tests/fixtures/sequel_ace/tls-off/macos/Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist @@ -0,0 +1,294 @@ + + + + + Favorites Root + + Children + + + colorIndex + -1 + database + shop + enableClearTextPlugin + 0 + host + db.example.com + id + 1 + name + TCP off + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + /certs/ca.pem + sslCACertFileLocationEnabled + 1 + sslCertificateFileLocation + + sslCertificateFileLocationEnabled + 0 + sslKeyFileLocation + + sslKeyFileLocationEnabled + 0 + type + 0 + useSSL + 0 + user + app + + + colorIndex + -1 + database + shop + enableClearTextPlugin + 0 + host + 127.0.0.1 + id + 2 + name + SSH off + port + 3306 + socket + + sshHost + bastion.example.com + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + 22 + sshUser + deploy + sslCACertFileLocation + /certs/ca.pem + sslCACertFileLocationEnabled + 1 + sslCertificateFileLocation + + sslCertificateFileLocationEnabled + 0 + sslKeyFileLocation + + sslKeyFileLocationEnabled + 0 + type + 2 + useSSL + 0 + user + app + + + colorIndex + -1 + database + shop + enableClearTextPlugin + 0 + host + + id + 3 + name + Socket off + port + 3306 + socket + /tmp/mysql.sock + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + /certs/ca.pem + sslCACertFileLocationEnabled + 1 + sslCertificateFileLocation + + sslCertificateFileLocationEnabled + 0 + sslKeyFileLocation + + sslKeyFileLocationEnabled + 0 + type + 1 + useSSL + 0 + user + app + + + colorIndex + -1 + database + shop + enableClearTextPlugin + 1 + host + ldap.example.com + id + 4 + name + Cleartext off + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + /certs/ca.pem + sslCACertFileLocationEnabled + 1 + sslCertificateFileLocation + + sslCertificateFileLocationEnabled + 0 + sslKeyFileLocation + + sslKeyFileLocationEnabled + 0 + type + 0 + useSSL + 0 + user + app + + + awsProfile + default + awsRegion + eu-west-1 + colorIndex + -1 + database + shop + enableClearTextPlugin + 0 + host + rds.example.com + id + 5 + name + IAM + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + /certs/ca.pem + sslCACertFileLocationEnabled + 1 + sslCertificateFileLocation + + sslCertificateFileLocationEnabled + 0 + sslKeyFileLocation + + sslKeyFileLocationEnabled + 0 + type + 3 + useAWSIAMAuth + 1 + useSSL + 0 + user + app + + + colorIndex + -1 + database + shop + enableClearTextPlugin + 0 + host + secure.example.com + id + 6 + name + TCP on + port + 3306 + socket + + sshHost + + sshKeyLocation + + sshKeyLocationEnabled + 0 + sshPort + + sshUser + + sslCACertFileLocation + /certs/ca.pem + sslCACertFileLocationEnabled + 1 + sslCertificateFileLocation + + sslCertificateFileLocationEnabled + 0 + sslKeyFileLocation + + sslKeyFileLocationEnabled + 0 + type + 0 + useSSL + 1 + user + app + + + + + diff --git a/core/crates/foreign-import/tests/review.rs b/core/crates/foreign-import/tests/review.rs index fbe7781a86..064776e01d 100644 --- a/core/crates/foreign-import/tests/review.rs +++ b/core/crates/foreign-import/tests/review.rs @@ -1922,3 +1922,54 @@ fn tls_settings_the_importer_could_not_read_are_noted_and_leave_the_row_unchecke assert!(!row.selected_by_default); assert!(row.connection.is_some(), "the row can still be checked by hand"); } + +#[test] +fn tls_the_other_app_used_with_its_ssl_setting_off_is_noted_and_keeps_the_row_checked() { + let defaulted = |mode| ForeignConnection { + ssl: Some(ForeignSsl { + mode, + ca_certificate_path: None, + client_certificate_path: None, + client_key_path: None, + }), + additional_fields: [("tlsFromDriverDefault".to_owned(), "true".to_owned())].into(), + ..settings() + }; + + let preferred = only_row(defaulted(ForeignSslMode::Preferred)); + assert_eq!( + preferred.notes, + [ + RowNote::TlsFromDriverDefault { + mode: ForeignSslMode::Preferred + }, + RowNote::TlsModeChanged { + from: ForeignSslMode::Preferred, + to: TlsMode::Require + }, + ] + ); + assert!(preferred.selected_by_default); + assert_eq!( + preferred.connection.expect("a supported engine").tls.mode, + TlsMode::Require + ); + + let verified = only_row(defaulted(ForeignSslMode::VerifyIdentity)); + assert_eq!( + verified.notes, + [RowNote::TlsFromDriverDefault { + mode: ForeignSslMode::VerifyIdentity + }] + ); + assert_eq!( + verified.connection.expect("a supported engine").tls.mode, + TlsMode::VerifyFull + ); + + let without_tls = only_row(ForeignConnection { + ssl: None, + ..defaulted(ForeignSslMode::Required) + }); + assert!(without_tls.notes.is_empty(), "{:?}", without_tls.notes); +} diff --git a/core/crates/foreign-import/tests/sequel_ace.rs b/core/crates/foreign-import/tests/sequel_ace.rs index fd7d1e4522..b202990f8f 100644 --- a/core/crates/foreign-import/tests/sequel_ace.rs +++ b/core/crates/foreign-import/tests/sequel_ace.rs @@ -368,9 +368,45 @@ fn a_boolean_use_ssl_turns_ssl_on() { } #[test] -fn there_is_no_ssl_when_use_ssl_is_zero() { +fn use_ssl_zero_still_prefers_tls_over_tcp_and_has_none_over_a_socket() { let import = collect("full", &request(false, false)); - assert!(connection(&import, "Local").settings.ssl.is_none()); + let local = &connection(&import, "Local").settings; + assert_eq!(local.ssl.as_ref().map(|ssl| ssl.mode), Some(ForeignSslMode::Preferred)); + assert!(connection(&import, "Socket DB").settings.ssl.is_none()); +} + +#[test] +fn with_ssl_off_the_tls_libmysqlclient_negotiates_is_imported_and_marked() { + let import = collect("tls-off", &request(false, false)); + let cases = [ + ("TCP off", Some(ForeignSslMode::Preferred), true), + ("SSH off", Some(ForeignSslMode::Preferred), true), + ("Socket off", None, false), + ("Cleartext off", Some(ForeignSslMode::Required), true), + ("IAM", Some(ForeignSslMode::Required), true), + ("TCP on", Some(ForeignSslMode::Required), false), + ]; + for (name, mode, marked) in cases { + let settings = &connection(&import, name).settings; + assert_eq!(settings.ssl.as_ref().map(|ssl| ssl.mode), mode, "{name}"); + assert_eq!( + settings.additional_fields.contains_key("tlsFromDriverDefault"), + marked, + "{name}" + ); + } + let ca = |name: &str| { + connection(&import, name) + .settings + .ssl + .as_ref() + .and_then(|ssl| ssl.ca_certificate_path.clone()) + }; + // Sequel Ace hands the certificate files over only with SSL on, which AWS IAM turns on. + assert_eq!(ca("TCP off"), None); + assert_eq!(ca("Cleartext off"), None); + assert_eq!(ca("IAM").as_deref(), Some("/certs/ca.pem")); + assert_eq!(ca("TCP on").as_deref(), Some("/certs/ca.pem")); } #[test] diff --git a/core/crates/foreign-import/tests/tableplus.rs b/core/crates/foreign-import/tests/tableplus.rs index 05a4820579..a1fb4de119 100644 --- a/core/crates/foreign-import/tests/tableplus.rs +++ b/core/crates/foreign-import/tests/tableplus.rs @@ -474,6 +474,16 @@ fn collect_keeps_preferred_for_a_driver_whose_form_has_no_tls_picker() { assert_eq!(ssl_mode(&collected, "Snowflake TLS"), Some(ForeignSslMode::Preferred)); } +#[test] +fn collect_says_sql_server_tls_comes_from_the_apps_freetds_default() { + let collected = macos(CONNECTIONS_ONLY); + let marker = |name: &str| field(connection(&collected, name), "tlsFromDriverDefault"); + assert_eq!(marker("SQL Server TLS"), Some("true")); + assert_eq!(marker("Snowflake TLS"), None); + assert_eq!(ssl_mode(&collected, "Conn MicrosoftSQLServer"), None); + assert_eq!(marker("Conn MicrosoftSQLServer"), None); +} + #[test] fn collect_treats_empty_tls_paths_as_none() { let collected = macos(CONNECTIONS_ONLY); diff --git a/linux/crates/app/src/ui/import_from_app/wording.rs b/linux/crates/app/src/ui/import_from_app/wording.rs index 9057333a8e..50a393725c 100644 --- a/linux/crates/app/src/ui/import_from_app/wording.rs +++ b/linux/crates/app/src/ui/import_from_app/wording.rs @@ -187,6 +187,10 @@ pub(super) fn note_text(note: &RowNote) -> String { &[("from", &ssl_mode_name(*from)), ("to", &tls_mode_name(*to))], ), RowNote::TlsSettingsNotRead => gettext("Its TLS settings could not be read, so it would connect without TLS"), + RowNote::TlsFromDriverDefault { mode } => gettext_f( + "SSL {mode} is on because the other app encrypts this connection by default", + &[("mode", &ssl_mode_name(*mode))], + ), RowNote::TlsNotKept { from } => gettext_f( "SSL {mode} is not kept: this engine connects without TLS", &[("mode", &ssl_mode_name(*from))], From 295e6d4cc9e4ced4620c2569f7bf67b19aaefbdb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 18:36:52 +0700 Subject: [PATCH 08/12] fix(core): keep importer fixtures byte for byte on a Windows checkout --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index 2573ca7f00..e96e4901e5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -61,3 +61,7 @@ apple/TablePro/Resources/ThirdPartyLicenses/texts/sqlite.txt -linguist-vendored Cargo.lock linguist-generated linux/data/licenses/** linguist-generated linux/build-aux/debian/copyright linguist-generated + +# Other apps' files as those apps write them, binary plists and encrypted blobs included: kept +# byte for byte, so a Windows checkout does not turn their line endings into CRLF. +core/crates/foreign-import/tests/fixtures/** -text From 0a8cc91bb4753ba320d11269d68ac709fbe0531a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 20:31:37 +0700 Subject: [PATCH 09/12] fix: drop the importer tests' sqlx dependency and list the import dialog in the a11y walk --- Cargo.lock | 2 +- core/crates/foreign-import/Cargo.toml | 11 +++-------- core/crates/foreign-import/tests/review.rs | 6 +++++- linux/crates/app/src/ui/a11y/tests.rs | 4 +++- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f80e0ea27c..e014e85bea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6364,11 +6364,11 @@ dependencies = [ "serde_json", "sha1", "sha2 0.11.0", - "sqlx", "tablepro-core", "tablepro-storage", "tempfile", "thiserror", + "tokio", "tokio-util", "tracing", "uuid", diff --git a/core/crates/foreign-import/Cargo.toml b/core/crates/foreign-import/Cargo.toml index e1fb27b70b..3dc33b8587 100644 --- a/core/crates/foreign-import/Cargo.toml +++ b/core/crates/foreign-import/Cargo.toml @@ -15,8 +15,8 @@ cbc.workspace = true chacha20.workspace = true flate2.workspace = true hex.workspace = true -# The same SQLite the storage crate links through sqlx, read directly -# because a Beekeeper Studio database is read once, blocking, read-only. +# The SQLite the storage crate links through sqlx, read directly because +# a Beekeeper Studio database is read once, blocking, read-only. libsqlite3-sys.workspace = true plist.workspace = true roxmltree.workspace = true @@ -31,14 +31,9 @@ tokio-util.workspace = true tracing.workspace = true uuid.workspace = true -[target.'cfg(target_os = "linux")'.dependencies] -sqlx = { workspace = true, features = ["sqlite-unbundled"] } - -[target.'cfg(not(target_os = "linux"))'.dependencies] -sqlx = { workspace = true, features = ["sqlite"] } - [dev-dependencies] tempfile.workspace = true +tokio = { workspace = true, features = ["rt"] } [lints] workspace = true diff --git a/core/crates/foreign-import/tests/review.rs b/core/crates/foreign-import/tests/review.rs index 064776e01d..b4db870be4 100644 --- a/core/crates/foreign-import/tests/review.rs +++ b/core/crates/foreign-import/tests/review.rs @@ -1853,7 +1853,11 @@ fn queries_land_on_their_connections_and_a_name_the_connection_holds_is_left_alo ] ); - let outcome = sqlx::test_block_on(async { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("a runtime"); + let outcome = runtime.block_on(async { let paths = StoragePaths::under(dir.path(), "tablepro", "app.tablepro.TablePro"); let saved_queries = QueryHistory::open(&paths).await.expect("the history").saved_queries(); saved_queries diff --git a/linux/crates/app/src/ui/a11y/tests.rs b/linux/crates/app/src/ui/a11y/tests.rs index 42809ff296..99da0cf5a6 100644 --- a/linux/crates/app/src/ui/a11y/tests.rs +++ b/linux/crates/app/src/ui/a11y/tests.rs @@ -8,7 +8,8 @@ use crate::test_support::{ }; use crate::ui::{ PreferencesDialog, SessionWindow, browse_tab, connect_dialog, data_grid, diagram, editor, export, filter_strip, - history_dialog, preferences_dialog, session_window, ssh_section, structure_tab, welcome_view, workspace, + history_dialog, import_from_app, preferences_dialog, session_window, ssh_section, structure_tab, welcome_view, + workspace, }; /// Every class the app's templates build. A template whose class is @@ -30,6 +31,7 @@ fn template_classes() -> Vec { filter_strip::FilterStrip::static_type(), history_dialog::HistoryDialog::static_type(), history_dialog::HistoryRow::static_type(), + import_from_app::ImportFromAppDialog::static_type(), session_window::ConnectFailedPage::static_type(), session_window::ConnectingPage::static_type(), session_window::PickerPage::static_type(), From fc51bb1c3aefa2a09347e739974a9f9f8b04337e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 01:32:06 +0700 Subject: [PATCH 10/12] fix(linux): wait for the query history before importing saved queries --- .../src/ui/session_window/import_from_app.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/import_from_app.rs b/linux/crates/app/src/ui/session_window/import_from_app.rs index 3f4f5b0f10..5493d9d436 100644 --- a/linux/crates/app/src/ui/session_window/import_from_app.rs +++ b/linux/crates/app/src/ui/session_window/import_from_app.rs @@ -35,17 +35,22 @@ impl SessionWindow { return; }; let editor = state.services.saved_connection_editor.clone(); - let saved_queries = state.services.history().store().map(|history| history.saved_queries()); + let history = state.services.history().ready_store(); crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { let message = match editor.add_imported(review, plan).await { Ok(imported) => { - let queries = match &saved_queries { - Some(store) if !imported.applied.queries.is_empty() => { - Some(editor.save_imported_queries(&imported.applied, store).await) - } - _ => None, + let (queries, queries_available) = if imported.applied.queries.is_empty() { + (None, false) + } else if let Some(history) = history.await { + let store = history.saved_queries(); + ( + Some(editor.save_imported_queries(&imported.applied, &store).await), + true, + ) + } else { + (None, false) }; - imported_message(&imported, queries, saved_queries.is_some()) + imported_message(&imported, queries, queries_available) } Err(SavedConnectionError::Storage(StorageError::ImportOutdated)) => { gettext("The saved connections changed while you were deciding. Import again.") From 2fbfed9a2280769d01d8c41034ef03f738d88fea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 03:58:35 +0700 Subject: [PATCH 11/12] test(windows): wait for the intended editor before typing --- .../tests/TablePro.App.UITests/AppSession.cs | 34 +++++++++++++++++-- .../TablePro.App.UITests/ShellPolishTests.cs | 6 ++-- .../tests/TablePro.App.UITests/ShellTests.cs | 12 ++++--- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/windows/tests/TablePro.App.UITests/AppSession.cs b/windows/tests/TablePro.App.UITests/AppSession.cs index cc2ff4e676..11fb7f7c94 100644 --- a/windows/tests/TablePro.App.UITests/AppSession.cs +++ b/windows/tests/TablePro.App.UITests/AppSession.cs @@ -77,17 +77,45 @@ public void Drag(Func from, Func to, Func< public void Chord(Func landed, params VirtualKeyShort[] keys) => Again(() => Keyboard.TypeSimultaneously(keys), landed, string.Join("+", keys)); - public void TypeInto(AutomationElement editor, string text) => + public void TypeInto(string title, string text) => Again( () => { - Click(editor); + var editor = FindEditor(title); + editor.Focus(); + if (!Retry.WhileFalse( + () => editor.Properties.HasKeyboardFocus.ValueOrDefault, + Wait, + throwOnTimeout: false).Success) + { + throw Failure($"The {title} editor did not take keyboard focus.", screen: true); + } + Keyboard.TypeSimultaneously(VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_A); Keyboard.Type(text); }, - () => EditorText(editor).TrimEnd('\r', '\n') == text, + () => EditorText(FindEditor(title)).TrimEnd('\r', '\n') == text, $"Typing {text}"); + public AutomationElement FindEditor(string title) => + Retry.WhileNull( + () => + { + // A tab header appears before its selected editor has finished layout. + var editor = Window.FindFirstDescendant(condition => + condition.ByAutomationId("SqlEditor").And(condition.ByName($"SQL editor, {title}"))); + if (editor is null || !editor.IsEnabled || editor.IsOffscreen) + { + return null; + } + + var bounds = editor.BoundingRectangle; + return bounds.Width > 0 && bounds.Height > 0 ? editor : null; + }, + Wait, + throwOnTimeout: false).Result + ?? throw Failure($"The {title} editor did not become ready.", screen: true); + private void Again(Action act, Func landed, string what) { for (var attempt = 0; attempt < 3; attempt++) diff --git a/windows/tests/TablePro.App.UITests/ShellPolishTests.cs b/windows/tests/TablePro.App.UITests/ShellPolishTests.cs index c748f20c53..9b049a9d0c 100644 --- a/windows/tests/TablePro.App.UITests/ShellPolishTests.cs +++ b/windows/tests/TablePro.App.UITests/ShellPolishTests.cs @@ -179,12 +179,12 @@ public void AClosedTabComesBackWithItsText(string theme) var tabs = app.Find("Tabs"); bool Open() => tabs.FindFirstDescendant(condition => condition.ByName("Query 2")) is not null; app.Chord(Open, VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_T); - app.TypeInto(app.Find("SqlEditor"), "SELECT 42 AS answer"); + app.TypeInto("Query 2", "SELECT 42 AS answer"); app.Chord(() => !Open(), VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_W); app.Chord(Open, VirtualKeyShort.CONTROL, VirtualKeyShort.SHIFT, VirtualKeyShort.KEY_T); var text = string.Empty; Retry.WhileFalse( - () => (text = AppSession.EditorText(app.Find("SqlEditor"))).Contains("SELECT 42", StringComparison.Ordinal), + () => (text = AppSession.EditorText(app.FindEditor("Query 2"))).Contains("SELECT 42", StringComparison.Ordinal), AppSession.Wait, throwOnTimeout: false); Assert.Contains("SELECT 42 AS answer", text, StringComparison.Ordinal); @@ -199,7 +199,7 @@ public void AnOpenTransactionHasItsBarsAndRollsBack(string theme) app.Click(app.Find("SampleCard"), () => app.TryFind("Rail_Chinook (Sample)") is not null); app.FindByName("Track", app.Find("ObjectTree")); - app.TypeInto(app.Find("SqlEditor"), "BEGIN"); + app.TypeInto("Query 1", "BEGIN"); app.Chord(() => app.TryFind("CommitButton") is not null, VirtualKeyShort.CONTROL, VirtualKeyShort.ENTER); // A closed InfoBar stays in the tree with its content folded away, so its buttons say whether it shows. diff --git a/windows/tests/TablePro.App.UITests/ShellTests.cs b/windows/tests/TablePro.App.UITests/ShellTests.cs index 5816d52626..28a0de9f39 100644 --- a/windows/tests/TablePro.App.UITests/ShellTests.cs +++ b/windows/tests/TablePro.App.UITests/ShellTests.cs @@ -97,10 +97,12 @@ public void ANewSqliteConnectionConnectsListsTablesAndOpensOne(string theme) Assert.StartsWith("Rows: 347", app.Find("TableStatus").Name, StringComparison.Ordinal); app.Screenshot("05-table-sorted"); - app.Find("Tabs"); - app.Chord(() => app.TryFind("SqlEditor") is not null, VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_T); - var editor = app.Find("SqlEditor"); - app.TypeInto(editor, "SELECT Name FROM Artist"); + var tabs = app.Find("Tabs"); + app.Chord( + () => tabs.FindFirstDescendant(condition => condition.ByName("Query 2")) is not null, + VirtualKeyShort.CONTROL, + VirtualKeyShort.KEY_T); + app.TypeInto("Query 2", "SELECT Name FROM Artist"); app.Chord( () => app.Find("QueryStatus").Name.StartsWith("Rows: 275", StringComparison.Ordinal), VirtualKeyShort.CONTROL, @@ -123,7 +125,7 @@ public void ANewSqliteConnectionConnectsListsTablesAndOpensOne(string theme) StringComparison.Ordinal); // A script cannot run again for a sort, so the rows it returned are sorted where they are. - app.TypeInto(editor, "SELECT Name FROM Artist ORDER BY ArtistId; SELECT 1"); + app.TypeInto("Query 2", "SELECT Name FROM Artist ORDER BY ArtistId; SELECT 1"); app.Chord( () => results.Patterns.Grid.Pattern.GetItem(0, 0)?.Properties.Name.ValueOrDefault == "AC/DC", VirtualKeyShort.CONTROL, From 52b4a752a3490f65e571fd7c2a9e608596115ce2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 10:39:18 +0700 Subject: [PATCH 12/12] test(linux): gate concurrent connection failure --- core/crates/session/src/fakes/fake_connector.rs | 11 +++++++++++ .../app/src/ui/session_window/tests/sessions.rs | 12 +++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/core/crates/session/src/fakes/fake_connector.rs b/core/crates/session/src/fakes/fake_connector.rs index c535c0dee9..014754b9c6 100644 --- a/core/crates/session/src/fakes/fake_connector.rs +++ b/core/crates/session/src/fakes/fake_connector.rs @@ -23,6 +23,11 @@ use crate::link::EstablishedConnection; pub enum FakeEstablish { Connect(ConnectionScript), Fail(ConnectError), + // Concurrent tests must finish their setup before a failure can change the visible state. + FailAfterRelease { + release: CancellationToken, + error: ConnectError, + }, /// Connects after `after` whatever the cancel token says, the way a /// driver blocked in a C call does. Late { @@ -192,6 +197,12 @@ impl Connector for FakeConnector { match reply { Some(FakeEstablish::Connect(script)) => Ok(connector.adopt(request, script)), Some(FakeEstablish::Fail(error)) => Err(error), + Some(FakeEstablish::FailAfterRelease { release, error }) => { + tokio::select! { + () = release.cancelled() => Err(error), + () = cancel.cancelled() => Err(ConnectError::Cancelled), + } + } Some(FakeEstablish::Late { after, script }) => { tokio::time::sleep(after).await; Ok(connector.adopt(request, script)) diff --git a/linux/crates/app/src/ui/session_window/tests/sessions.rs b/linux/crates/app/src/ui/session_window/tests/sessions.rs index 429a12dfea..1d970de0c6 100644 --- a/linux/crates/app/src/ui/session_window/tests/sessions.rs +++ b/linux/crates/app/src/ui/session_window/tests/sessions.rs @@ -419,17 +419,27 @@ fn failed_beside(name: &str, failure: FakeEstablish) -> (TestWindow, SessionKey, /// to the page that shows the other connect from then on. #[gtk4::test] fn a_failure_while_another_connect_runs_toasts_on_the_page_left_on_screen() { + let release = tokio_util::sync::CancellationToken::new(); let test = TestWindow::with_script( "FailedWhileConnecting", Script { - establish: vec![refused(), FakeEstablish::UntilCancelled], + establish: vec![ + FakeEstablish::FailAfterRelease { + release: release.clone(), + error: ConnectError::Driver(DriverError::Auth { diagnostics: None }), + }, + FakeEstablish::UntilCancelled, + ], ..Script::default() }, ); let other = second_connection(&test); test.window .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || test.connector.calls().len() == 1).expect("the first connect to reach its failure gate"); test.window.send(SessionWindowInput::OpenSaved(Box::new(other))); + wait_until(WAIT, || test.connector.calls().len() == 2).expect("the second connect to remain pending"); + release.cancel(); let title = format!("Could not connect to “{}�", test.saved.name); let on_screen = || {