1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[PM-25521] Move importer metadata to native code (#16695)

* Add importer metadata to native code

* Impl napi code in ts

* Impl napi code in ts

* Fix clippy

* Fix clippy

* remove ts util tests

* Check for installed browsers

* PR fixes

* test fix

* fix clippy

* fix tests

* Bug fix

* clippy fix

* Correct tests

* fix clippy

* fix clippy

* Correct tests

* Correct tests

* [PM-25521] Wire up loading metadata on desktop (#16813)

* Initial commit

* Fix issues regarding now unused feature flag

* Fixed ts-strict issues

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by: adudek-bw <adudek@bitwarden.com>

* Remove logic to skip Brave as that now happens via the native code

* Define default capabilities which can be overwritten by specifc client/platform

* Fix DI issues

* Do not overwrite existing importers, just add new ones or update existing ones

* feat: [PM-25521] return metadata directly (not as JSON) (#16882)

* feat: return metadata directly (not as JSON)

* Fix broken builds

Move getMetaData into chromium_importer
Remove chromium_importer_metadata and any related service
Parse object from native instead of json

* Run cargo fmt

* Fix cargo dependency sort order

* Use exposed type from NAPI instead of redefining it.

* Run cargo fmt

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>

* Only enable chromium loader for installed and supported browsers

---------

Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
This commit is contained in:
adudek-bw
2025-10-17 09:46:10 -04:00
committed by GitHub
parent 9ba1de702e
commit 7015663c38
32 changed files with 641 additions and 337 deletions

View File

@@ -221,8 +221,10 @@ import {
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
import {
DefaultImportMetadataService,
ImportApiService,
ImportApiServiceAbstraction,
ImportMetadataServiceAbstraction,
ImportService,
ImportServiceAbstraction,
} from "@bitwarden/importer-core";
@@ -368,6 +370,7 @@ export default class MainBackground {
authService: AuthServiceAbstraction;
loginEmailService: LoginEmailServiceAbstraction;
importApiService: ImportApiServiceAbstraction;
importMetadataService: ImportMetadataServiceAbstraction;
importService: ImportServiceAbstraction;
exportApiService: VaultExportApiService;
exportService: VaultExportServiceAbstraction;
@@ -1088,6 +1091,18 @@ export default class MainBackground {
this.importApiService = new ImportApiService(this.apiService);
this.importMetadataService = new DefaultImportMetadataService(
createSystemServiceProvider(
new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService),
this.stateProvider,
this.policyService,
buildExtensionRegistry(),
this.logService,
this.platformUtilsService,
this.configService,
),
);
this.importService = new ImportService(
this.cipherService,
this.folderService,
@@ -1099,15 +1114,6 @@ export default class MainBackground {
this.pinService,
this.accountService,
this.restrictedItemTypesService,
createSystemServiceProvider(
new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService),
this.stateProvider,
this.policyService,
buildExtensionRegistry(),
this.logService,
this.platformUtilsService,
this.configService,
),
);
this.individualVaultExportService = new IndividualVaultExportService(

View File

@@ -4,7 +4,16 @@ import { Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components";
import { ImportComponent } from "@bitwarden/importer-ui";
import {
DefaultImportMetadataService,
ImportMetadataServiceAbstraction,
} from "@bitwarden/importer-core";
import {
ImportComponent,
ImporterProviders,
SYSTEM_SERVICE_PROVIDER,
} from "@bitwarden/importer-ui";
import { safeProvider } from "@bitwarden/ui-common";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
@@ -25,6 +34,14 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page
PopupHeaderComponent,
PopOutComponent,
],
providers: [
...ImporterProviders,
safeProvider({
provide: ImportMetadataServiceAbstraction,
useClass: DefaultImportMetadataService,
deps: [SYSTEM_SERVICE_PROVIDER],
}),
],
})
export class ImportBrowserV2Component {
protected disabled = false;

View File

@@ -151,8 +151,10 @@ import {
PasswordGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
import {
DefaultImportMetadataService,
ImportApiService,
ImportApiServiceAbstraction,
ImportMetadataServiceAbstraction,
ImportService,
ImportServiceAbstraction,
} from "@bitwarden/importer-core";
@@ -252,6 +254,7 @@ export class ServiceContainer {
auditService: AuditService;
importService: ImportServiceAbstraction;
importApiService: ImportApiServiceAbstraction;
importMetadataService: ImportMetadataServiceAbstraction;
exportService: VaultExportServiceAbstraction;
vaultExportApiService: VaultExportApiService;
individualExportService: IndividualVaultExportServiceAbstraction;
@@ -845,6 +848,18 @@ export class ServiceContainer {
this.importApiService = new ImportApiService(this.apiService);
this.importMetadataService = new DefaultImportMetadataService(
createSystemServiceProvider(
new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService),
this.stateProvider,
this.policyService,
buildExtensionRegistry(),
this.logService,
this.platformUtilsService,
this.configService,
),
);
this.importService = new ImportService(
this.cipherService,
this.folderService,
@@ -856,15 +871,6 @@ export class ServiceContainer {
this.pinService,
this.accountService,
this.restrictedItemTypesService,
createSystemServiceProvider(
new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService),
this.stateProvider,
this.policyService,
buildExtensionRegistry(),
this.logService,
this.platformUtilsService,
this.configService,
),
);
this.individualExportService = new IndividualVaultExportService(

View File

@@ -450,6 +450,8 @@ dependencies = [
"cbc",
"hex",
"homedir",
"napi",
"napi-derive",
"oo7",
"pbkdf2",
"rand 0.9.1",

View File

@@ -14,6 +14,8 @@ base64 = { workspace = true }
cbc = { workspace = true, features = ["alloc"] }
hex = { workspace = true }
homedir = { workspace = true }
napi = { workspace = true }
napi-derive = { workspace = true }
pbkdf2 = "=0.12.2"
rand = { workspace = true }
rusqlite = { version = "=0.37.0", features = ["bundled"] }

View File

@@ -11,7 +11,7 @@ use rusqlite::{params, Connection};
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
mod platform;
pub mod platform;
//
// Public API
@@ -50,18 +50,26 @@ pub enum LoginImportResult {
Failure(LoginImportFailure),
}
// TODO: Make thus async
pub fn get_installed_browsers() -> Result<Vec<String>> {
let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len());
pub trait InstalledBrowserRetriever {
fn get_installed_browsers() -> Result<Vec<String>>;
}
for (browser, config) in SUPPORTED_BROWSER_MAP.iter() {
let data_dir = get_browser_data_dir(config)?;
if data_dir.exists() {
browsers.push((*browser).to_string());
pub struct DefaultInstalledBrowserRetriever {}
impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever {
// TODO: Make thus async
fn get_installed_browsers() -> Result<Vec<String>> {
let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len());
for (browser, config) in SUPPORTED_BROWSER_MAP.iter() {
let data_dir = get_browser_data_dir(config)?;
if data_dir.exists() {
browsers.push((*browser).to_string());
}
}
}
Ok(browsers)
Ok(browsers)
}
}
// TODO: Make thus async
@@ -104,13 +112,13 @@ pub async fn import_logins(
// Private
//
#[derive(Debug)]
struct BrowserConfig {
name: &'static str,
data_dir: &'static str,
#[derive(Debug, Clone, Copy)]
pub struct BrowserConfig {
pub name: &'static str,
pub data_dir: &'static str,
}
static SUPPORTED_BROWSER_MAP: LazyLock<
pub static SUPPORTED_BROWSER_MAP: LazyLock<
std::collections::HashMap<&'static str, &'static BrowserConfig>,
> = LazyLock::new(|| {
platform::SUPPORTED_BROWSERS
@@ -132,12 +140,12 @@ fn get_browser_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
//
#[async_trait]
trait CryptoService: Send {
pub trait CryptoService: Send {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String>;
}
#[derive(serde::Deserialize, Clone)]
struct LocalState {
pub struct LocalState {
profile: AllProfiles,
#[allow(dead_code)]
os_crypt: Option<OsCrypt>,

View File

@@ -1 +1,8 @@
#[macro_use]
extern crate napi_derive;
pub mod chromium;
pub mod metadata;
pub mod util;
pub use crate::chromium::platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS;

View File

@@ -6,7 +6,7 @@ use oo7::XDG_SCHEMA_ATTRIBUTE;
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
mod util;
use crate::util;
//
// Public API

View File

@@ -4,7 +4,7 @@ use security_framework::passwords::get_generic_password;
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
mod util;
use crate::util;
//
// Public API

View File

@@ -0,0 +1,203 @@
use std::collections::{HashMap, HashSet};
use crate::{chromium::InstalledBrowserRetriever, PLATFORM_SUPPORTED_BROWSERS};
#[napi(object)]
/// Mechanisms that load data into the importer
pub struct NativeImporterMetadata {
/// Identifies the importer
pub id: String,
/// Describes the strategies used to obtain imported data
pub loaders: Vec<&'static str>,
/// Identifies the instructions for the importer
pub instructions: &'static str,
}
/// Returns a map of supported importers based on the current platform.
///
/// Only browsers listed in PLATFORM_SUPPORTED_BROWSERS will have the "chromium" loader.
/// All importers will have the "file" loader.
pub fn get_supported_importers<T: InstalledBrowserRetriever>(
) -> HashMap<String, NativeImporterMetadata> {
let mut map = HashMap::new();
// Check for installed browsers
let installed_browsers = T::get_installed_browsers().unwrap_or_default();
const IMPORTERS: [(&str, &str); 6] = [
("chromecsv", "Chrome"),
("chromiumcsv", "Chromium"),
("bravecsv", "Brave"),
("operacsv", "Opera"),
("vivaldicsv", "Vivaldi"),
("edgecsv", "Microsoft Edge"),
];
let supported: HashSet<&'static str> =
PLATFORM_SUPPORTED_BROWSERS.iter().map(|b| b.name).collect();
for (id, browser_name) in IMPORTERS {
let mut loaders: Vec<&'static str> = vec!["file"];
if supported.contains(browser_name) {
loaders.push("chromium");
}
if installed_browsers.contains(&browser_name.to_string()) {
map.insert(
id.to_string(),
NativeImporterMetadata {
id: id.to_string(),
loaders,
instructions: "chromium",
},
);
}
}
map
}
/*
Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage
*/
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use crate::chromium::{InstalledBrowserRetriever, SUPPORTED_BROWSER_MAP};
pub struct MockInstalledBrowserRetriever {}
impl InstalledBrowserRetriever for MockInstalledBrowserRetriever {
fn get_installed_browsers() -> Result<Vec<String>, anyhow::Error> {
Ok(SUPPORTED_BROWSER_MAP
.keys()
.map(|browser| browser.to_string())
.collect())
}
}
fn map_keys(map: &HashMap<String, NativeImporterMetadata>) -> HashSet<String> {
map.keys().cloned().collect()
}
fn get_loaders(
map: &HashMap<String, NativeImporterMetadata>,
id: &str,
) -> HashSet<&'static str> {
map.get(id)
.map(|m| m.loaders.iter().copied().collect::<HashSet<_>>())
.unwrap_or_default()
}
#[cfg(target_os = "macos")]
#[test]
fn macos_returns_all_known_importers() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let expected: HashSet<String> = HashSet::from([
"chromecsv".to_string(),
"chromiumcsv".to_string(),
"bravecsv".to_string(),
"operacsv".to_string(),
"vivaldicsv".to_string(),
"edgecsv".to_string(),
]);
assert_eq!(map.len(), expected.len());
assert_eq!(map_keys(&map), expected);
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
}
}
#[cfg(target_os = "macos")]
#[test]
fn macos_specific_loaders_match_const_array() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let ids = [
"chromecsv",
"chromiumcsv",
"bravecsv",
"operacsv",
"vivaldicsv",
"edgecsv",
];
for id in ids {
let loaders = get_loaders(&map, id);
assert!(loaders.contains("file"));
assert!(loaders.contains("chromium"), "missing chromium for {id}");
}
}
#[cfg(target_os = "linux")]
#[test]
fn returns_all_known_importers() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let expected: HashSet<String> = HashSet::from([
"chromecsv".to_string(),
"chromiumcsv".to_string(),
"bravecsv".to_string(),
"operacsv".to_string(),
]);
assert_eq!(map.len(), expected.len());
assert_eq!(map_keys(&map), expected);
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
}
}
#[cfg(target_os = "linux")]
#[test]
fn linux_specific_loaders_match_const_array() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let ids = ["chromecsv", "chromiumcsv", "bravecsv", "operacsv"];
for id in ids {
let loaders = get_loaders(&map, id);
assert!(loaders.contains("file"));
assert!(loaders.contains("chromium"), "missing chromium for {id}");
}
}
#[cfg(target_os = "windows")]
#[test]
fn returns_all_known_importers() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let expected: HashSet<String> = HashSet::from([
"chromiumcsv".to_string(),
"edgecsv".to_string(),
"operacsv".to_string(),
"vivaldicsv".to_string(),
]);
assert_eq!(map.len(), expected.len());
assert_eq!(map_keys(&map), expected);
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
}
}
#[cfg(target_os = "windows")]
#[test]
fn windows_specific_loaders_match_const_array() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let ids = ["chromiumcsv", "edgecsv", "operacsv", "vivaldicsv"];
for id in ids {
let loaders = get_loaders(&map, id);
assert!(loaders.contains("file"));
assert!(loaders.contains("chromium"), "missing chromium for {id}");
}
}
}

View File

@@ -9,18 +9,14 @@ use windows::Win32::Foundation::{LocalFree, HLOCAL};
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
#[allow(dead_code)]
mod util;
use crate::util;
//
// Public API
//
pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [
BrowserConfig {
name: "Chrome",
data_dir: "AppData/Local/Google/Chrome/User Data",
},
// IMPORTANT adjust array size when enabling / disabling chromium importers here
pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [
BrowserConfig {
name: "Chromium",
data_dir: "AppData/Local/Chromium/User Data",
@@ -29,10 +25,6 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [
name: "Microsoft Edge",
data_dir: "AppData/Local/Microsoft/Edge/User Data",
},
BrowserConfig {
name: "Brave",
data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data",
},
BrowserConfig {
name: "Opera",
data_dir: "AppData/Roaming/Opera Software/Opera Stable",

View File

@@ -3,6 +3,15 @@
/* auto-generated by NAPI-RS */
/** Mechanisms that load data into the importer */
export interface NativeImporterMetadata {
/** Identifies the importer */
id: string
/** Describes the strategies used to obtain imported data */
loaders: Array<string>
/** Identifies the instructions for the importer */
instructions: string
}
export declare namespace passwords {
/** The error message returned when a password is not found during retrieval or deletion. */
export const PASSWORD_NOT_FOUND: string
@@ -228,6 +237,8 @@ export declare namespace chromium_importer {
login?: Login
failure?: LoginImportFailure
}
/** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */
export function getMetadata(): Record<string, NativeImporterMetadata>
export function getInstalledBrowsers(): Array<string>
export function getAvailableProfiles(browser: string): Array<ProfileInfo>
export function importLogins(browser: string, profileId: string): Promise<Array<LoginImportResult>>

View File

@@ -944,8 +944,12 @@ pub mod logging {
#[napi]
pub mod chromium_importer {
use bitwarden_chromium_importer::chromium::DefaultInstalledBrowserRetriever;
use bitwarden_chromium_importer::chromium::InstalledBrowserRetriever;
use bitwarden_chromium_importer::chromium::LoginImportResult as _LoginImportResult;
use bitwarden_chromium_importer::chromium::ProfileInfo as _ProfileInfo;
use bitwarden_chromium_importer::metadata::NativeImporterMetadata;
use std::collections::HashMap;
#[napi(object)]
pub struct ProfileInfo {
@@ -1007,9 +1011,17 @@ pub mod chromium_importer {
}
}
#[napi]
/// Returns OS aware metadata describing supported Chromium based importers as a JSON string.
pub fn get_metadata() -> HashMap<String, NativeImporterMetadata> {
bitwarden_chromium_importer::metadata::get_supported_importers::<
DefaultInstalledBrowserRetriever,
>()
}
#[napi]
pub fn get_installed_browsers() -> napi::Result<Vec<String>> {
bitwarden_chromium_importer::chromium::get_installed_browsers()
bitwarden_chromium_importer::chromium::DefaultInstalledBrowserRetriever::get_installed_browsers()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}

View File

@@ -4,6 +4,10 @@ import { chromium_importer } from "@bitwarden/desktop-napi";
export class ChromiumImporterService {
constructor() {
ipcMain.handle("chromium_importer.getMetadata", async (event) => {
return await chromium_importer.getMetadata();
});
ipcMain.handle("chromium_importer.getInstalledBrowsers", async (event) => {
return await chromium_importer.getInstalledBrowsers();
});

View File

@@ -0,0 +1,66 @@
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import type { NativeImporterMetadata } from "@bitwarden/desktop-napi";
import {
ImportType,
DefaultImportMetadataService,
ImportMetadataServiceAbstraction,
DataLoader,
ImporterMetadata,
InstructionLink,
Instructions,
Loader,
} from "@bitwarden/importer-core";
export class DesktopImportMetadataService
extends DefaultImportMetadataService
implements ImportMetadataServiceAbstraction
{
constructor(system: SystemServiceProvider) {
super(system);
}
async init(): Promise<void> {
const metadata = await ipc.tools.chromiumImporter.getMetadata();
await this.parseNativeMetaData(metadata);
await super.init();
}
private async parseNativeMetaData(raw: Record<string, NativeImporterMetadata>): Promise<void> {
const entries = Object.entries(raw).map(([id, meta]) => {
const loaders = meta.loaders.map(this.mapLoader);
const instructions = this.mapInstructions(meta.instructions);
const mapped: ImporterMetadata = {
type: id as ImportType,
loaders,
...(instructions ? { instructions } : {}),
};
return [id, mapped] as const;
});
// Do not overwrite existing importers, just add new ones or update existing ones
this.importers = {
...this.importers,
...Object.fromEntries(entries),
};
}
private mapLoader(name: string): DataLoader {
switch (name) {
case "file":
return Loader.file;
case "chromium":
return Loader.chromium;
default:
throw new Error(`Unknown loader from native module: ${name}`);
}
}
private mapInstructions(name: string): InstructionLink | undefined {
switch (name) {
case "chromium":
return Instructions.chromium;
default:
return undefined;
}
}
}

View File

@@ -3,7 +3,15 @@ import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { DialogRef, AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components";
import { ImportComponent } from "@bitwarden/importer-ui";
import { ImportMetadataServiceAbstraction } from "@bitwarden/importer-core";
import {
ImportComponent,
ImporterProviders,
SYSTEM_SERVICE_PROVIDER,
} from "@bitwarden/importer-ui";
import { safeProvider } from "@bitwarden/ui-common";
import { DesktopImportMetadataService } from "./desktop-import-metadata.service";
@Component({
templateUrl: "import-desktop.component.html",
@@ -15,6 +23,14 @@ import { ImportComponent } from "@bitwarden/importer-ui";
ButtonModule,
ImportComponent,
],
providers: [
...ImporterProviders,
safeProvider({
provide: ImportMetadataServiceAbstraction,
useClass: DesktopImportMetadataService,
deps: [SYSTEM_SERVICE_PROVIDER],
}),
],
})
export class ImportDesktopComponent {
protected disabled = false;

View File

@@ -1,6 +1,10 @@
import { ipcRenderer } from "electron";
import type { NativeImporterMetadata } from "@bitwarden/desktop-napi";
const chromiumImporter = {
getMetadata: (): Promise<Record<string, NativeImporterMetadata>> =>
ipcRenderer.invoke("chromium_importer.getMetadata"),
getInstalledBrowsers: (): Promise<string[]> =>
ipcRenderer.invoke("chromium_importer.getInstalledBrowsers"),
getAvailableProfiles: (browser: string): Promise<any[]> =>

View File

@@ -1,7 +1,16 @@
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { ImportComponent } from "@bitwarden/importer-ui";
import {
DefaultImportMetadataService,
ImportMetadataServiceAbstraction,
} from "@bitwarden/importer-core";
import {
ImportComponent,
ImporterProviders,
SYSTEM_SERVICE_PROVIDER,
} from "@bitwarden/importer-ui";
import { safeProvider } from "@bitwarden/ui-common";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
@@ -9,6 +18,14 @@ import { SharedModule } from "../../shared";
@Component({
templateUrl: "import-web.component.html",
imports: [SharedModule, ImportComponent, HeaderModule],
providers: [
...ImporterProviders,
safeProvider({
provide: ImportMetadataServiceAbstraction,
useClass: DefaultImportMetadataService,
deps: [SYSTEM_SERVICE_PROVIDER],
}),
],
})
export class ImportWebComponent {
protected loading = false;

View File

@@ -72,7 +72,11 @@ import {
import { ImporterMetadata, DataLoader, Loader, Instructions } from "../metadata";
import { ImportOption, ImportResult, ImportType } from "../models";
import { ImportCollectionServiceAbstraction, ImportServiceAbstraction } from "../services";
import {
ImportCollectionServiceAbstraction,
ImportMetadataServiceAbstraction,
ImportServiceAbstraction,
} from "../services";
import { ImportChromeComponent } from "./chrome";
import {
@@ -236,6 +240,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
protected accountService: AccountService,
private restrictedItemTypesService: RestrictedItemTypesService,
private destroyRef: DestroyRef,
protected importMetadataService: ImportMetadataServiceAbstraction,
) {}
protected get importBlockedByPolicy(): boolean {
@@ -254,9 +259,11 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
}
async ngOnInit() {
await this.importMetadataService.init();
this.setImportOptions();
this.importService
this.importMetadataService
.metadata$(this.formGroup.controls.format.valueChanges)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({

View File

@@ -37,7 +37,9 @@ import {
// FIXME: unify with `SYSTEM_SERVICE_PROVIDER` when migrating it from the generator component module
// to a general module.
const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken<SystemServiceProvider>("SystemServices");
export const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken<SystemServiceProvider>(
"SystemServices",
);
/** Import service factories */
export const ImporterProviders: SafeProvider[] = [
@@ -85,7 +87,6 @@ export const ImporterProviders: SafeProvider[] = [
PinServiceAbstraction,
AccountService,
RestrictedItemTypesService,
SYSTEM_SERVICE_PROVIDER,
],
}),
];

View File

@@ -1,3 +1,4 @@
export * from "./dialog";
export * from "./importer-providers";
export { ImportComponent } from "./import.component";

View File

@@ -1,4 +1,5 @@
export * from "./models";
export * from "./metadata";
export * from "./services";
export { Importer } from "./importers/importer";

View File

@@ -2,26 +2,32 @@ import { deepFreeze } from "@bitwarden/common/tools/util";
import { ImportType } from "../models";
import { Loader, Instructions } from "./data";
import { Instructions, Loader } from "./data";
import { ImporterMetadata } from "./types";
// FIXME: load this data from rust code
export type ImportersMetadata = Partial<Record<ImportType, ImporterMetadata>>;
/** List of all supported importers and their default capabilities
* Note: the loaders listed here are the ones that are supported in all clients.
* Specific clients may have additional loaders available based on platform capabilities.
*/
const importers = [
{ id: "bitwardenjson", loaders: [Loader.file], instructions: Instructions.unique },
// chromecsv import depends upon operating system, so ironically it doesn't support chromium
{ id: "chromecsv", loaders: [Loader.file], instructions: Instructions.chromium },
{ id: "operacsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium },
{ id: "operacsv", loaders: [Loader.file], instructions: Instructions.chromium },
{
id: "vivaldicsv",
loaders: [Loader.file, Loader.chromium],
loaders: [Loader.file],
instructions: Instructions.chromium,
},
{ id: "bravecsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium },
{ id: "edgecsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium },
{ id: "bravecsv", loaders: [Loader.file], instructions: Instructions.chromium },
{ id: "edgecsv", loaders: [Loader.file], instructions: Instructions.chromium },
// FIXME: add other formats and remove `Partial` from export
] as const;
/** Describes which loaders are available for each import type */
export const Importers: Partial<Record<ImportType, ImporterMetadata>> = deepFreeze(
export const Importers: ImportersMetadata = deepFreeze(
Object.fromEntries(importers.map((i) => [i.id, i])),
);

View File

@@ -0,0 +1,51 @@
import { map, Observable } from "rxjs";
import { SemanticLogger } from "@bitwarden/common/tools/log";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import { ImporterMetadata, Importers, ImportersMetadata } from "../metadata";
import { ImportType } from "../models/import-options";
import { availableLoaders } from "../util";
import { ImportMetadataServiceAbstraction } from "./import-metadata.service.abstraction";
export class DefaultImportMetadataService implements ImportMetadataServiceAbstraction {
protected importers: ImportersMetadata = Importers;
private logger: SemanticLogger;
constructor(protected system: SystemServiceProvider) {
this.logger = system.log({ type: "ImportMetadataService" });
}
async init(): Promise<void> {
// no-op for default implementation
}
metadata$(type$: Observable<ImportType>): Observable<ImporterMetadata> {
const client = this.system.environment.getClientType();
const capabilities$ = type$.pipe(
map((type) => {
if (!this.importers) {
return { type, loaders: [] };
}
const loaders = availableLoaders(this.importers, type, client);
if (!loaders || loaders.length === 0) {
return { type, loaders: [] };
}
const capabilities: ImporterMetadata = { type, loaders };
if (type in this.importers) {
capabilities.instructions = this.importers[type]?.instructions;
}
this.logger.debug({ importType: type, capabilities }, "capabilities updated");
return capabilities;
}),
);
return capabilities$;
}
}

View File

@@ -0,0 +1,11 @@
import { Observable } from "rxjs";
import { ImporterMetadata } from "../metadata";
import { ImportType } from "../models/import-options";
export abstract class ImportMetadataServiceAbstraction {
abstract init(): Promise<void>;
/** describes the features supported by a format */
abstract metadata$: (type$: Observable<ImportType>) => Observable<ImporterMetadata>;
}

View File

@@ -0,0 +1,110 @@
import { mock, MockProxy } from "jest-mock-extended";
import { Subject, firstValueFrom } from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import { ImporterMetadata, Instructions } from "../metadata";
import { ImportType } from "../models";
import { DefaultImportMetadataService } from "./default-import-metadata.service";
import { ImportMetadataServiceAbstraction } from "./import-metadata.service.abstraction";
describe("ImportMetadataService", () => {
let sut: ImportMetadataServiceAbstraction;
let systemServiceProvider: MockProxy<SystemServiceProvider>;
beforeEach(() => {
const configService = mock<ConfigService>();
const environment = mock<PlatformUtilsService>();
environment.getClientType.mockReturnValue(ClientType.Desktop);
systemServiceProvider = mock<SystemServiceProvider>({
configService,
environment,
log: jest.fn().mockReturnValue({ debug: jest.fn() }),
});
sut = new DefaultImportMetadataService(systemServiceProvider);
});
describe("metadata$", () => {
let typeSubject: Subject<ImportType>;
let mockLogger: { debug: jest.Mock };
beforeEach(() => {
typeSubject = new Subject<ImportType>();
mockLogger = { debug: jest.fn() };
const configService = mock<ConfigService>();
const environment = mock<PlatformUtilsService>();
environment.getClientType.mockReturnValue(ClientType.Desktop);
systemServiceProvider = mock<SystemServiceProvider>({
configService,
environment,
log: jest.fn().mockReturnValue(mockLogger),
});
// Recreate the service with the updated mocks for logging tests
sut = new DefaultImportMetadataService(systemServiceProvider);
});
afterEach(() => {
typeSubject.complete();
});
it("should emit metadata when type$ emits", async () => {
const testType: ImportType = "chromecsv";
const metadataPromise = firstValueFrom(sut.metadata$(typeSubject));
typeSubject.next(testType);
const result = await metadataPromise;
expect(result).toEqual({
type: testType,
loaders: expect.any(Array),
instructions: Instructions.chromium,
});
expect(result.type).toBe(testType);
});
it("should update when type$ changes", async () => {
const emissions: ImporterMetadata[] = [];
const subscription = sut.metadata$(typeSubject).subscribe((metadata) => {
emissions.push(metadata);
});
typeSubject.next("chromecsv");
typeSubject.next("bravecsv");
// Wait for emissions
await new Promise((resolve) => setTimeout(resolve, 0));
expect(emissions).toHaveLength(2);
expect(emissions[0].type).toBe("chromecsv");
expect(emissions[1].type).toBe("bravecsv");
subscription.unsubscribe();
});
it("should log debug information with correct data", async () => {
const testType: ImportType = "chromecsv";
const metadataPromise = firstValueFrom(sut.metadata$(typeSubject));
typeSubject.next(testType);
await metadataPromise;
expect(mockLogger.debug).toHaveBeenCalledWith(
{ importType: testType, capabilities: expect.any(Object) },
"capabilities updated",
);
});
});
});

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -8,7 +7,6 @@ import { CollectionView } from "@bitwarden/admin-console/common";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { Importer } from "../importers/importer";
import { ImporterMetadata } from "../metadata";
import { ImportOption, ImportType } from "../models/import-options";
import { ImportResult } from "../models/import-result";
@@ -17,9 +15,6 @@ export abstract class ImportServiceAbstraction {
regularImportOptions: readonly ImportOption[];
getImportOptions: () => ImportOption[];
/** describes the features supported by a format */
metadata$: (type$: Observable<ImportType>) => Observable<ImporterMetadata>;
import: (
importer: Importer,
fileContents: string,

View File

@@ -1,20 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, Subject, firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { ClientType } from "@bitwarden/client-type";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -25,8 +18,6 @@ import { KeyService } from "@bitwarden/key-management";
import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer";
import { Importer } from "../importers/importer";
import { ImporterMetadata, Instructions, Loader } from "../metadata";
import { ImportType } from "../models";
import { ImportResult } from "../models/import-result";
import { ImportApiServiceAbstraction } from "./import-api.service.abstraction";
@@ -44,7 +35,6 @@ describe("ImportService", () => {
let pinService: MockProxy<PinServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let restrictedItemTypesService: MockProxy<RestrictedItemTypesService>;
let systemServiceProvider: MockProxy<SystemServiceProvider>;
beforeEach(() => {
cipherService = mock<CipherService>();
@@ -57,18 +47,6 @@ describe("ImportService", () => {
pinService = mock<PinServiceAbstraction>();
restrictedItemTypesService = mock<RestrictedItemTypesService>();
const configService = mock<ConfigService>();
configService.getFeatureFlag$.mockReturnValue(new BehaviorSubject(false));
const environment = mock<PlatformUtilsService>();
environment.getClientType.mockReturnValue(ClientType.Desktop);
systemServiceProvider = mock<SystemServiceProvider>({
configService,
environment,
log: jest.fn().mockReturnValue({ debug: jest.fn() }),
});
importService = new ImportService(
cipherService,
folderService,
@@ -80,7 +58,6 @@ describe("ImportService", () => {
pinService,
accountService,
restrictedItemTypesService,
systemServiceProvider,
);
});
@@ -268,136 +245,6 @@ describe("ImportService", () => {
expect(importResult.folderRelationships[1]).toEqual([0, 1]);
});
});
describe("metadata$", () => {
let featureFlagSubject: BehaviorSubject<boolean>;
let typeSubject: Subject<ImportType>;
let mockLogger: { debug: jest.Mock };
beforeEach(() => {
featureFlagSubject = new BehaviorSubject(false);
typeSubject = new Subject<ImportType>();
mockLogger = { debug: jest.fn() };
const configService = mock<ConfigService>();
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject);
const environment = mock<PlatformUtilsService>();
environment.getClientType.mockReturnValue(ClientType.Desktop);
systemServiceProvider = mock<SystemServiceProvider>({
configService,
environment,
log: jest.fn().mockReturnValue(mockLogger),
});
// Recreate the service with the updated mocks for logging tests
importService = new ImportService(
cipherService,
folderService,
importApiService,
i18nService,
collectionService,
keyService,
encryptService,
pinService,
accountService,
restrictedItemTypesService,
systemServiceProvider,
);
});
afterEach(() => {
featureFlagSubject.complete();
typeSubject.complete();
});
it("should emit metadata when type$ emits", async () => {
const testType: ImportType = "chromecsv";
const metadataPromise = firstValueFrom(importService.metadata$(typeSubject));
typeSubject.next(testType);
const result = await metadataPromise;
expect(result).toEqual({
type: testType,
loaders: expect.any(Array),
instructions: Instructions.chromium,
});
expect(result.type).toBe(testType);
});
it("should include all loaders when chromium feature flag is enabled", async () => {
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
featureFlagSubject.next(true);
const metadataPromise = firstValueFrom(importService.metadata$(typeSubject));
typeSubject.next(testType);
const result = await metadataPromise;
expect(result.loaders).toContain(Loader.chromium);
expect(result.loaders).toContain(Loader.file);
});
it("should update when type$ changes", async () => {
const emissions: ImporterMetadata[] = [];
const subscription = importService.metadata$(typeSubject).subscribe((metadata) => {
emissions.push(metadata);
});
typeSubject.next("chromecsv");
typeSubject.next("bravecsv");
// Wait for emissions
await new Promise((resolve) => setTimeout(resolve, 0));
expect(emissions).toHaveLength(2);
expect(emissions[0].type).toBe("chromecsv");
expect(emissions[1].type).toBe("bravecsv");
subscription.unsubscribe();
});
it("should update when both type$ and feature flag change", async () => {
const emissions: ImporterMetadata[] = [];
const subscription = importService.metadata$(typeSubject).subscribe((metadata) => {
emissions.push(metadata);
});
// Initial emission
typeSubject.next("chromecsv");
// Change both at the same time
featureFlagSubject.next(true);
typeSubject.next("bravecsv");
// Wait for emissions
await new Promise((resolve) => setTimeout(resolve, 0));
expect(emissions.length).toBeGreaterThanOrEqual(2);
const lastEmission = emissions[emissions.length - 1];
expect(lastEmission.type).toBe("bravecsv");
subscription.unsubscribe();
});
it("should log debug information with correct data", async () => {
const testType: ImportType = "chromecsv";
const metadataPromise = firstValueFrom(importService.metadata$(typeSubject));
typeSubject.next(testType);
await metadataPromise;
expect(mockLogger.debug).toHaveBeenCalledWith(
{ importType: testType, capabilities: expect.any(Object) },
"capabilities updated",
);
});
});
});
function createCipher(options: Partial<CipherView> = {}) {

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, firstValueFrom, map, Observable } from "rxjs";
import { firstValueFrom, map } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -11,7 +11,6 @@ import {
} from "@bitwarden/admin-console/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DeviceType } from "@bitwarden/common/enums";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { ImportCiphersRequest } from "@bitwarden/common/models/request/import-ciphers.request";
@@ -20,8 +19,6 @@ import { KvpRequest } from "@bitwarden/common/models/request/kvp.request";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SemanticLogger } from "@bitwarden/common/tools/log";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -98,7 +95,6 @@ import {
PasswordDepot17XmlImporter,
} from "../importers";
import { Importer } from "../importers/importer";
import { ImporterMetadata, Importers, Loader } from "../metadata";
import {
featuredImportOptions,
ImportOption,
@@ -108,15 +104,12 @@ import {
import { ImportResult } from "../models/import-result";
import { ImportApiServiceAbstraction } from "../services/import-api.service.abstraction";
import { ImportServiceAbstraction } from "../services/import.service.abstraction";
import { availableLoaders as availableLoaders } from "../util";
export class ImportService implements ImportServiceAbstraction {
featuredImportOptions = featuredImportOptions as readonly ImportOption[];
regularImportOptions = regularImportOptions as readonly ImportOption[];
private logger: SemanticLogger;
constructor(
private cipherService: CipherService,
private folderService: FolderService,
@@ -128,55 +121,12 @@ export class ImportService implements ImportServiceAbstraction {
private pinService: PinServiceAbstraction,
private accountService: AccountService,
private restrictedItemTypesService: RestrictedItemTypesService,
private system: SystemServiceProvider,
) {
this.logger = system.log({ type: "ImportService" });
}
) {}
getImportOptions(): ImportOption[] {
return this.featuredImportOptions.concat(this.regularImportOptions);
}
metadata$(type$: Observable<ImportType>): Observable<ImporterMetadata> {
const client = this.system.environment.getClientType();
const capabilities$ = combineLatest([type$]).pipe(
map(([type]) => {
let loaders = availableLoaders(type, client);
// Mac App Store is currently disabled due to sandboxing.
let isUnsupported = this.system.environment.isMacAppStore();
// disable the chromium loader for Brave on Windows only
if (type === "bravecsv") {
try {
const device = this.system.environment.getDevice();
const isWindowsDesktop = device === DeviceType.WindowsDesktop;
if (isWindowsDesktop) {
isUnsupported = true;
}
} catch {
isUnsupported = true;
}
}
// If the browser is unsupported, remove the chromium loader
if (isUnsupported) {
loaders = loaders?.filter((loader) => loader !== Loader.chromium);
}
const capabilities: ImporterMetadata = { type, loaders };
if (type in Importers) {
capabilities.instructions = Importers[type].instructions;
}
this.logger.debug({ importType: type, capabilities }, "capabilities updated");
return capabilities;
}),
);
return capabilities$;
}
async import(
importer: Importer,
fileContents: string,

View File

@@ -4,4 +4,7 @@ export { ImportApiService } from "./import-api.service";
export { ImportServiceAbstraction } from "./import.service.abstraction";
export { ImportService } from "./import.service";
export { ImportMetadataServiceAbstraction } from "./import-metadata.service.abstraction";
export { DefaultImportMetadataService } from "./default-import-metadata.service";
export { ImportCollectionServiceAbstraction } from "./import-collection.service.abstraction";

View File

@@ -1,60 +0,0 @@
import { ClientType } from "@bitwarden/client-type";
import { Loader } from "./metadata";
import { availableLoaders } from "./util";
describe("availableLoaders", () => {
describe("given valid import types", () => {
it("returns available loaders when client supports all loaders", () => {
const result = availableLoaders("operacsv", ClientType.Desktop);
expect(result).toEqual([Loader.file, Loader.chromium]);
});
it("returns filtered loaders when client supports some loaders", () => {
const result = availableLoaders("operacsv", ClientType.Browser);
expect(result).toEqual([Loader.file]);
});
it("returns single loader for import types with one loader", () => {
const result = availableLoaders("chromecsv", ClientType.Desktop);
expect(result).toEqual([Loader.file]);
});
it("returns all supported loaders for multi-loader import types", () => {
const result = availableLoaders("bravecsv", ClientType.Desktop);
expect(result).toEqual([Loader.file, Loader.chromium]);
});
});
describe("given unknown import types", () => {
it("returns undefined when import type is not found in metadata", () => {
const result = availableLoaders("nonexistent" as any, ClientType.Desktop);
expect(result).toBeUndefined();
});
});
describe("given different client types", () => {
it("returns appropriate loaders for Browser client", () => {
const result = availableLoaders("operacsv", ClientType.Browser);
expect(result).toEqual([Loader.file]);
});
it("returns appropriate loaders for Web client", () => {
const result = availableLoaders("chromecsv", ClientType.Web);
expect(result).toEqual([Loader.file]);
});
it("returns appropriate loaders for CLI client", () => {
const result = availableLoaders("vivaldicsv", ClientType.Cli);
expect(result).toEqual([Loader.file]);
});
});
});

View File

@@ -1,6 +1,6 @@
import { ClientType } from "@bitwarden/client-type";
import { LoaderAvailability, Importers } from "./metadata";
import { LoaderAvailability, ImportersMetadata } from "./metadata";
import { ImportType } from "./models";
/** Lookup the loaders supported by a specific client.
@@ -8,12 +8,20 @@ import { ImportType } from "./models";
* @returns `undefined` when metadata is not defined for the type, or
* an array identifying the supported clients.
*/
export function availableLoaders(type: ImportType, client: ClientType) {
if (!(type in Importers)) {
export function availableLoaders(
importersMetadata: ImportersMetadata,
type: ImportType,
client: ClientType,
) {
if (!importersMetadata) {
return undefined;
}
const capabilities = Importers[type]?.loaders ?? [];
if (!(type in importersMetadata)) {
return undefined;
}
const capabilities = importersMetadata[type]?.loaders ?? [];
const available = capabilities.filter((loader) => LoaderAvailability[loader].includes(client));
return available;
}