1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-24748][PM-24072] Chromium importer (#16100)

* Add importer dummy lib, add cargo deps for win/mac

* Add Chromium importer source from bitwarden/password-access

* Mod crypto is no more

* Expose some Chromium importer functions via NAPI, replace home with home_dir crate

* Add Chromium importer to the main <-> renderer IPC, export all functions from Rust

* Add password and notes fields to the imported logins

* Fix windows to use homedir instead of home

* Return success/failure results

* Import from account logins and join

* Linux v10 support

* Use mod util on Windows

* Use mod util on macOS

* Refactor to move shared code into chromium.rs

* Fix windows

* Fix Linux as well

* Linux v11 support for Chrome/Gnome, everything is async now

* Support multiple browsers on Linux v11

* Move oo7 to Linux

* Fix Windows

* Fix macOS

* Add support for Brave browser in Linux configuration

* Add support for Opera browser in Linux configuration

* Fix Edge and add Arc on macOS

* Add Opera on macOS

* Add support for Vivaldi browser in macOS configuration

* Add support for Chromium browser in macOS configuration

* Fix Edge on Windows

* Add Opera on Windows

* Add Vivaldi on windows

* Add Chromium to supported browsers on Windows

* stub out UI options for chromium direct import

* call IPC funcs from import-desktop

* add notes to chrome csv importer

* remove (csv) from import tool names and format item names as hostnames

* Add ABE/v20 encryption support

* ABE/v20 architecture description

* Add a build step to produce admin.exe and service.exe

* Add Windows v20/ABE configuration functionality to specify the full path to the admin.exe and service.exe. Use ipc.platform.chromiumImporter.configureWindowsCryptoService to configure the Chromium importer on Windows.

* rename ARCHITECTURE.md to README.md

* aligns with guidance from architecture re: in-repository documentation.
* also fixes a failing lint.

* cargo fmt

* cargo clippy fix

* Declare feature flag for using chromium importer

* Linter fix after executing npm run prettier

* Use feature flag to guard the use of the chromium importer

* Added temporary logging to further debug, why the Angular change detection isn't working as expected

* introduce importer metadata; host metadata from service; includes tests

* fix cli build

* Register autotype module in lib.rs
introduce by a bad merge

* Fix web build

* Fix issue with loaders being undefined and the feature flag turned off

* Add missing Chromium support when selecting chromecsv

* debugging

* remove chromium support from chromecsv metadata

* fix default loader selection

* [PM-24753] cargo lib file (#16090)

* Add new modules

* Fix chromium importer

* Fix compile bugs for toolchain

* remove importer folder

* remove IPC code

* undo setting change

* clippy fixes

* cargo fmt

* clippy fixes

* clippy fixes

* clippy fixes

* clippy fixes

* lint fix

* fix release build

* Add files in CODEOWNERS

* Create tools owned preload.ts

* Move chromium-importer.service under tools-ownership

* Fix typeError
When accessing the Chromium direct import options the file button is hidden, so trying to access it's values will fail

* Fix tools owned preload

* Remove dead code and redundant truncation

* Remove configureWindowsCryptoService function/methods

* Clean up cargo files

* Fix unused async

* Update apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Fix napi deps

* fix lints

* format

* fix linux lint

* fix windows lints

* format

* fix missing `?`

* fix a different missing `?`

---------

Co-authored-by: Dmitry Yakimenko <detunized@gmail.com>
Co-authored-by: Kyle Spearrin <kyle.spearrin@gmail.com>
Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by:  Audrey  <ajensen@bitwarden.com>
Co-authored-by:  Audrey  <audrey@audreyality.com>
Co-authored-by: adudek-bw <adudek@bitwarden.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
Daniel James Smith
2025-09-04 11:21:57 +02:00
committed by GitHub
parent b957a0c28f
commit 66f5700a75
46 changed files with 2436 additions and 90 deletions

View File

@@ -37,6 +37,7 @@ export enum FeatureFlag {
/* Tools */
DesktopSendUIRefresh = "desktop-send-ui-refresh",
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
UseChromiumImporter = "pm-23982-chromium-importer",
/* DIRT */
EventBasedOrganizationIntegrations = "event-based-organization-integrations",
@@ -79,6 +80,7 @@ export const DefaultFeatureFlagValue = {
/* Tools */
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
[FeatureFlag.UseChromiumImporter]: FALSE,
/* DIRT */
[FeatureFlag.EventBasedOrganizationIntegrations]: FALSE,

View File

@@ -0,0 +1,178 @@
import { mock, MockProxy } from "jest-mock-extended";
import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction";
import { ConfigService } from "../platform/abstractions/config/config.service";
import { LogService } from "../platform/abstractions/log.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
import { StateProvider } from "../platform/state";
import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider";
import { ExtensionRegistry } from "./extension/extension-registry.abstraction";
import { ExtensionService } from "./extension/extension.service";
import { disabledSemanticLoggerProvider } from "./log";
import { createSystemServiceProvider } from "./providers";
describe("SystemServiceProvider", () => {
let mockEncryptor: LegacyEncryptorProvider;
let mockState: StateProvider;
let mockPolicy: PolicyService;
let mockRegistry: ExtensionRegistry;
let mockLogger: LogService;
let mockEnvironment: MockProxy<PlatformUtilsService>;
let mockConfigService: ConfigService;
beforeEach(() => {
jest.resetAllMocks();
mockEncryptor = mock<LegacyEncryptorProvider>();
mockState = mock<StateProvider>();
mockPolicy = mock<PolicyService>();
mockRegistry = mock<ExtensionRegistry>();
mockLogger = mock<LogService>();
mockEnvironment = mock<PlatformUtilsService>();
mockConfigService = mock<ConfigService>();
});
describe("createSystemServiceProvider", () => {
it("returns object with all required services when called with valid parameters", () => {
mockEnvironment.isDev.mockReturnValue(false);
const result = createSystemServiceProvider(
mockEncryptor,
mockState,
mockPolicy,
mockRegistry,
mockLogger,
mockEnvironment,
mockConfigService,
);
expect(result).toHaveProperty("policy", mockPolicy);
expect(result).toHaveProperty("extension");
expect(result).toHaveProperty("log");
expect(result).toHaveProperty("configService", mockConfigService);
expect(result).toHaveProperty("environment", mockEnvironment);
expect(result.extension).toBeInstanceOf(ExtensionService);
});
it("creates ExtensionService with correct dependencies when called", () => {
mockEnvironment.isDev.mockReturnValue(true);
const result = createSystemServiceProvider(
mockEncryptor,
mockState,
mockPolicy,
mockRegistry,
mockLogger,
mockEnvironment,
mockConfigService,
);
expect(result.extension).toBeInstanceOf(ExtensionService);
});
describe("given development environment", () => {
it("uses enableLogForTypes when environment.isDev() returns true", () => {
mockEnvironment.isDev.mockReturnValue(true);
const result = createSystemServiceProvider(
mockEncryptor,
mockState,
mockPolicy,
mockRegistry,
mockLogger,
mockEnvironment,
mockConfigService,
);
expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1);
expect(result.log).not.toBe(disabledSemanticLoggerProvider);
});
});
describe("given production environment", () => {
it("uses disabledSemanticLoggerProvider when environment.isDev() returns false", () => {
mockEnvironment.isDev.mockReturnValue(false);
const result = createSystemServiceProvider(
mockEncryptor,
mockState,
mockPolicy,
mockRegistry,
mockLogger,
mockEnvironment,
mockConfigService,
);
expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1);
expect(result.log).toBe(disabledSemanticLoggerProvider);
});
});
it("configures ExtensionService with encryptor, state, log provider, and now function when called", () => {
mockEnvironment.isDev.mockReturnValue(false);
const dateSpy = jest.spyOn(Date, "now");
const result = createSystemServiceProvider(
mockEncryptor,
mockState,
mockPolicy,
mockRegistry,
mockLogger,
mockEnvironment,
mockConfigService,
);
expect(result.extension).toBeInstanceOf(ExtensionService);
expect(dateSpy).not.toHaveBeenCalled();
});
it("passes through policy service correctly when called", () => {
mockEnvironment.isDev.mockReturnValue(false);
const result = createSystemServiceProvider(
mockEncryptor,
mockState,
mockPolicy,
mockRegistry,
mockLogger,
mockEnvironment,
mockConfigService,
);
expect(result.policy).toBe(mockPolicy);
});
it("passes through configService correctly when called", () => {
mockEnvironment.isDev.mockReturnValue(false);
const result = createSystemServiceProvider(
mockEncryptor,
mockState,
mockPolicy,
mockRegistry,
mockLogger,
mockEnvironment,
mockConfigService,
);
expect(result.configService).toBe(mockConfigService);
});
it("passes through environment service correctly when called", () => {
mockEnvironment.isDev.mockReturnValue(false);
const result = createSystemServiceProvider(
mockEncryptor,
mockState,
mockPolicy,
mockRegistry,
mockLogger,
mockEnvironment,
mockConfigService,
);
expect(result.environment).toBe(mockEnvironment);
});
});
});

View File

@@ -1,10 +1,15 @@
import { LogService } from "@bitwarden/logging";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { StateProvider } from "@bitwarden/state";
import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction";
import { ConfigService } from "../platform/abstractions/config/config.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider";
import { ExtensionRegistry } from "./extension/extension-registry.abstraction";
import { ExtensionService } from "./extension/extension.service";
import { LogProvider } from "./log";
import { disabledSemanticLoggerProvider, enableLogForTypes, LogProvider } from "./log";
/** Provides access to commonly-used cross-cutting services. */
export type SystemServiceProvider = {
@@ -20,6 +25,42 @@ export type SystemServiceProvider = {
/** Config Service to determine flag features */
readonly configService: ConfigService;
/** Platform Service to inspect runtime environment */
readonly environment: PlatformUtilsService;
/** SDK Service */
readonly sdk: BitwardenClient;
readonly sdk?: BitwardenClient;
};
/** Constructs a system service provider. */
export function createSystemServiceProvider(
encryptor: LegacyEncryptorProvider,
state: StateProvider,
policy: PolicyService,
registry: ExtensionRegistry,
logger: LogService,
environment: PlatformUtilsService,
configService: ConfigService,
): SystemServiceProvider {
let log: LogProvider;
if (environment.isDev()) {
log = enableLogForTypes(logger, []);
} else {
log = disabledSemanticLoggerProvider;
}
const extension = new ExtensionService(registry, {
encryptor,
state,
log,
now: Date.now,
});
return {
policy,
extension,
log,
configService,
environment,
};
}

View File

@@ -0,0 +1,8 @@
<div [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "browserProfile" | i18n }}</bit-label>
<bit-select formControlName="profile">
<bit-option *ngFor="let p of profileList" [value]="p.id" [label]="p.name" />
</bit-select>
</bit-form-field>
</div>

View File

@@ -0,0 +1,167 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import {
AsyncValidatorFn,
ControlContainer,
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from "@angular/forms";
import * as papa from "papaparse";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
CalloutModule,
CheckboxModule,
FormFieldModule,
IconButtonModule,
SelectModule,
TypographyModule,
} from "@bitwarden/components";
import { ImportType } from "../../models";
@Component({
selector: "import-chrome",
templateUrl: "import-chrome.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
CalloutModule,
TypographyModule,
FormFieldModule,
ReactiveFormsModule,
IconButtonModule,
CheckboxModule,
SelectModule,
],
})
export class ImportChromeComponent implements OnInit, OnDestroy {
private _parentFormGroup: FormGroup;
protected formGroup = this.formBuilder.group({
profile: [
"",
{
nonNullable: true,
validators: [Validators.required],
asyncValidators: [this.validateAndEmitData()],
updateOn: "submit",
},
],
});
profileList: { id: string; name: string }[] = [];
@Input()
format: ImportType;
@Input()
onLoadProfilesFromBrowser: (browser: string) => Promise<any[]>;
@Input()
onImportFromBrowser: (browser: string, profile: string) => Promise<any[]>;
@Output() csvDataLoaded = new EventEmitter<string>();
constructor(
private formBuilder: FormBuilder,
private controlContainer: ControlContainer,
private logService: LogService,
private i18nService: I18nService,
) {}
async ngOnInit(): Promise<void> {
this._parentFormGroup = this.controlContainer.control as FormGroup;
this._parentFormGroup.addControl("chromeOptions", this.formGroup);
this.profileList = await this.onLoadProfilesFromBrowser(this.getBrowserName());
}
ngOnDestroy(): void {
this._parentFormGroup.removeControl("chromeOptions");
}
/**
* Attempts to login to the provided Chrome email and retrieve account contents.
* Will return a validation error if unable to login or fetch.
* Emits account contents to `csvDataLoaded`
*/
validateAndEmitData(): AsyncValidatorFn {
return async () => {
try {
const logins = await this.onImportFromBrowser(
this.getBrowserName(),
this.formGroup.controls.profile.value,
);
if (logins.length === 0) {
throw "nothing to import";
}
const chromeLogins: ChromeLogin[] = [];
for (const l of logins) {
if (l.login != null) {
chromeLogins.push(new ChromeLogin(l.login));
}
}
const csvData = papa.unparse(chromeLogins);
this.csvDataLoaded.emit(csvData);
return null;
} catch (error) {
this.logService.error(`Chromium importer error: ${error}`);
return {
errors: {
message: this.i18nService.t(this.getValidationErrorI18nKey(error)),
},
};
}
};
}
private getValidationErrorI18nKey(error: any): string {
const message = typeof error === "string" ? error : error?.message;
switch (message) {
default:
return "errorOccurred";
}
}
private getBrowserName(): string {
if (this.format === "edgecsv") {
return "Microsoft Edge";
} else if (this.format === "operacsv") {
return "Opera";
} else if (this.format === "bravecsv") {
return "Brave";
} else if (this.format === "vivaldicsv") {
return "Vivaldi";
}
return "Chrome";
}
}
class ChromeLogin {
name: string;
url: string;
username: string;
password: string;
note: string;
constructor(login: any) {
const url = Utils.getUrl(login?.url);
if (url != null) {
this.name = new URL(url).hostname;
}
if (this.name == null) {
this.name = login.url;
}
this.url = login.url;
this.username = login.username;
this.password = login.password;
this.note = login.note;
}
}

View File

@@ -0,0 +1 @@
export { ImportChromeComponent } from "./import-chrome.component";

View File

@@ -169,27 +169,41 @@
"Export" to save the JSON file.
</ng-container>
-->
<ng-container
*ngIf="
format === 'chromecsv' ||
format === 'operacsv' ||
format === 'vivaldicsv' ||
format === 'edgecsv'
"
>
<ng-container *ngIf="showChromiumInstructions$ | async">
<span *ngIf="format !== 'chromecsv'">
The process is exactly the same as importing from Google Chrome.
</span>
See detailed instructions on our help site at
<a
bitLink
linkType="primary"
target="_blank"
rel="noreferrer"
href="https://bitwarden.com/help/import-from-chrome/"
>
https://bitwarden.com/help/import-from-chrome/</a
<p>
See detailed instructions on our help site at
<a
bitLink
linkType="primary"
target="_blank"
rel="noreferrer"
href="https://bitwarden.com/help/import-from-chrome/"
>
https://bitwarden.com/help/import-from-chrome/</a
>
</p>
<bit-radio-group
[hidden]="!(browserImporterAvailable$ | async)"
formControlName="chromiumLoader"
>
<bit-radio-button
class="tw-block"
id="import_bit-radio-button_chrome-browser"
value="chromium"
>
<bit-label>{{ "importDirectlyFromBrowser" | i18n }}</bit-label>
</bit-radio-button>
<bit-radio-button
class="tw-block"
id="import_bit-radio-button_chrome-file"
value="file"
>
<bit-label>{{ "importFromCSV" | i18n }}</bit-label>
</bit-radio-button>
</bit-radio-group>
</ng-container>
<ng-container *ngIf="format === 'firefoxcsv'">
See detailed instructions on our help site at
@@ -440,12 +454,20 @@
previously chosen.
</ng-container>
</bit-callout>
<import-lastpass
*ngIf="showLastPassOptions"
[formGroup]="formGroup"
(csvDataLoaded)="this.formGroup.controls.fileContents.setValue($event)"
></import-lastpass>
<div [hidden]="showLastPassOptions">
@if (showLastPassOptions) {
<import-lastpass
[formGroup]="formGroup"
(csvDataLoaded)="this.formGroup.controls.fileContents.setValue($event)"
></import-lastpass>
} @else if (showChromiumOptions$ | async) {
<import-chrome
[formGroup]="formGroup"
[onImportFromBrowser]="this.onImportFromBrowser"
[onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser"
[format]="this.format"
(csvDataLoaded)="this.formGroup.controls.fileContents.setValue($event)"
></import-chrome>
} @else {
<bit-form-field>
<bit-label>{{ "selectImportFile" | i18n }}</bit-label>
<div class="file-selector tw-pt-2 tw-pb-1 tw-break-words">
@@ -473,7 +495,7 @@
formControlName="fileContents"
></textarea>
</bit-form-field>
</div>
}
</bit-card>
</bit-section>
</form>

View File

@@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common";
import {
AfterViewInit,
Component,
DestroyRef,
EventEmitter,
Inject,
Input,
@@ -13,17 +14,23 @@ import {
Output,
ViewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import * as JSZip from "jszip";
import { Observable, Subject, lastValueFrom, combineLatest, firstValueFrom } from "rxjs";
import {
Observable,
Subject,
lastValueFrom,
combineLatest,
firstValueFrom,
BehaviorSubject,
} from "rxjs";
import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/operators";
// 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 { JslibModule } from "@bitwarden/angular/jslib.module";
import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
getOrganizationById,
OrganizationService,
@@ -34,14 +41,10 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ClientType } 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -62,49 +65,20 @@ import {
ToastService,
LinkModule,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { ImporterMetadata, DataLoader, Loader, Instructions } from "../metadata";
import { ImportOption, ImportResult, ImportType } from "../models";
import {
ImportApiService,
ImportApiServiceAbstraction,
ImportCollectionServiceAbstraction,
ImportService,
ImportServiceAbstraction,
} from "../services";
import { ImportCollectionServiceAbstraction, ImportServiceAbstraction } from "../services";
import { ImportChromeComponent } from "./chrome";
import {
FilePasswordPromptComponent,
ImportErrorDialogComponent,
ImportSuccessDialogComponent,
} from "./dialog";
import { ImporterProviders } from "./importer-providers";
import { ImportLastPassComponent } from "./lastpass";
const safeProviders: SafeProvider[] = [
safeProvider({
provide: ImportApiServiceAbstraction,
useClass: ImportApiService,
deps: [ApiService],
}),
safeProvider({
provide: ImportServiceAbstraction,
useClass: ImportService,
deps: [
CipherService,
FolderService,
ImportApiServiceAbstraction,
I18nService,
CollectionService,
KeyService,
EncryptService,
PinServiceAbstraction,
AccountService,
SdkService,
RestrictedItemTypesService,
],
}),
];
@Component({
selector: "tools-import",
templateUrl: "import.component.html",
@@ -118,6 +92,7 @@ const safeProviders: SafeProvider[] = [
SelectModule,
CalloutModule,
ReactiveFormsModule,
ImportChromeComponent,
ImportLastPassComponent,
RadioButtonModule,
CardComponent,
@@ -125,7 +100,7 @@ const safeProviders: SafeProvider[] = [
SectionComponent,
LinkModule,
],
providers: safeProviders,
providers: ImporterProviders,
})
export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
featuredImportOptions: ImportOption[];
@@ -160,6 +135,12 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
});
}
@Input()
onLoadProfilesFromBrowser: (browser: string) => Promise<any[]>;
@Input()
onImportFromBrowser: (browser: string, profile: string) => Promise<any[]>;
protected organization: Organization;
protected destroy$ = new Subject<void>();
@@ -184,6 +165,8 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
fileContents: [],
file: [],
lastPassType: ["direct" as "csv" | "direct"],
// FIXME: once the flag is disabled this should initialize to `Strategy.browser`
chromiumLoader: [Loader.file as DataLoader],
});
@ViewChild(BitSubmitDirective)
@@ -208,6 +191,26 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
});
}
private importer$ = new BehaviorSubject<ImporterMetadata | undefined>(undefined);
/** emits `true` when the chromium instruction block should be visible. */
protected readonly showChromiumInstructions$ = this.importer$.pipe(
map((importer) => importer?.instructions === Instructions.chromium),
);
/** emits `true` when direct browser import is available. */
// FIXME: use the capabilities list to populate `chromiumLoader` and replace the explicit
// strategy check with a check for multiple loaders
protected readonly browserImporterAvailable$ = this.importer$.pipe(
map((importer) => (importer?.loaders ?? []).includes(Loader.chromium)),
);
/** emits `true` when the chromium loader is selected. */
protected readonly showChromiumOptions$ =
this.formGroup.controls.chromiumLoader.valueChanges.pipe(
map((chromiumLoader) => chromiumLoader === Loader.chromium),
);
constructor(
protected i18nService: I18nService,
protected importService: ImportServiceAbstraction,
@@ -226,6 +229,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
protected toastService: ToastService,
protected accountService: AccountService,
private restrictedItemTypesService: RestrictedItemTypesService,
private destroyRef: DestroyRef,
) {}
protected get importBlockedByPolicy(): boolean {
@@ -246,6 +250,23 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
async ngOnInit() {
this.setImportOptions();
this.importService
.metadata$(this.formGroup.controls.format.valueChanges)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (importer) => {
this.importer$.next(importer);
// when an importer is defined, the loader needs to be set to a value from
// its list.
const loader = importer.loaders.includes(Loader.chromium)
? Loader.chromium
: importer.loaders?.[0];
this.formGroup.controls.chromiumLoader.setValue(loader ?? Loader.file);
},
error: (err: unknown) => this.logService.error("an error occurred", err),
});
if (this.organizationId) {
await this.handleOrganizationImportInit();
} else {
@@ -578,7 +599,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit {
private async setImportContents(): Promise<string> {
const fileEl = document.getElementById("import_input_file") as HTMLInputElement;
const files = fileEl.files;
const files = fileEl?.files;
let fileContents = this.formGroup.controls.fileContents.value;
if (files != null && files.length > 0) {

View File

@@ -0,0 +1,91 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// 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 } from "@bitwarden/admin-console/common";
import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider";
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
import { ExtensionRegistry } from "@bitwarden/common/tools/extension/extension-registry.abstraction";
import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory";
import {
createSystemServiceProvider,
SystemServiceProvider,
} from "@bitwarden/common/tools/providers";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { KeyService } from "@bitwarden/key-management";
import { StateProvider } from "@bitwarden/state";
import { SafeInjectionToken } from "@bitwarden/ui-common";
import {
ImportApiService,
ImportApiServiceAbstraction,
ImportService,
ImportServiceAbstraction,
} from "../services";
// 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");
/** Import service factories */
export const ImporterProviders: SafeProvider[] = [
safeProvider({
provide: ImportApiServiceAbstraction,
useClass: ImportApiService,
deps: [ApiService],
}),
safeProvider({
provide: LegacyEncryptorProvider,
useClass: KeyServiceLegacyEncryptorProvider,
deps: [EncryptService, KeyService],
}),
safeProvider({
provide: ExtensionRegistry,
useFactory: () => {
return buildExtensionRegistry();
},
deps: [],
}),
safeProvider({
provide: SYSTEM_SERVICE_PROVIDER,
useFactory: createSystemServiceProvider,
deps: [
LegacyEncryptorProvider,
StateProvider,
PolicyService,
ExtensionRegistry,
LogService,
PlatformUtilsService,
ConfigService,
],
}),
safeProvider({
provide: ImportServiceAbstraction,
useClass: ImportService,
deps: [
CipherService,
FolderService,
ImportApiServiceAbstraction,
I18nService,
CollectionService,
KeyService,
EncryptService,
PinServiceAbstraction,
AccountService,
RestrictedItemTypesService,
SYSTEM_SERVICE_PROVIDER,
],
}),
];

View File

@@ -24,6 +24,7 @@ export class ChromeCsvImporter extends BaseImporter implements Importer {
cipher.login.username = this.getValueOrDefault(value.username);
cipher.login.password = this.getValueOrDefault(value.password);
cipher.login.uris = this.makeUriArray(value.url);
cipher.notes = this.getValueOrDefault(value.note);
this.cleanupCipher(cipher);
result.ciphers.push(cipher);
});

View File

@@ -0,0 +1,15 @@
import { ClientType } from "@bitwarden/client-type";
import { deepFreeze } from "@bitwarden/common/tools/util";
import { Loader } from "./data";
import { DataLoader } from "./types";
/** Describes which loaders are supported on each client */
export const LoaderAvailability: Record<DataLoader, ClientType[]> = deepFreeze({
[Loader.chromium]: [ClientType.Desktop],
[Loader.download]: [ClientType.Browser],
[Loader.file]: [ClientType.Browser, ClientType.Desktop, ClientType.Web, ClientType.Cli],
// FIXME: enable IPC importer on `ClientType.Desktop` once it's ready
[Loader.ipc]: [],
});

View File

@@ -0,0 +1,27 @@
/** Mechanisms that load data into the importer. */
export const Loader = Object.freeze({
/** Data loaded from a file provided by the user/ */
file: "file",
/** Data loaded directly from the chromium browser's data store */
chromium: "chromium",
/** Data provided through an importer ipc channel (e.g. Bitwarden bridge) */
ipc: "ipc",
/** Data provided through direct file download (e.g. a LastPass export) */
download: "download",
});
/** Re-branded products often leave their exporters unaltered; when that occurs,
* `Instructions` lets us group them together.
*
* @remarks Instructions values must be mutually exclusive from Loader's values.
*/
export const Instructions = Object.freeze({
/** the instructions are unique to the import type */
unique: "unique",
/** shared chromium instructions */
chromium: "chromium",
});

View File

@@ -0,0 +1,27 @@
import { deepFreeze } from "@bitwarden/common/tools/util";
import { ImportType } from "../models";
import { Loader, Instructions } from "./data";
import { ImporterMetadata } from "./types";
// FIXME: load this data from rust code
const importers = [
// 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: "vivaldicsv",
loaders: [Loader.file, Loader.chromium],
instructions: Instructions.chromium,
},
{ id: "bravecsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium },
{ id: "edgecsv", loaders: [Loader.file, Loader.chromium], 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(
Object.fromEntries(importers.map((i) => [i.id, i])),
);

View File

@@ -0,0 +1,4 @@
export * from "./availability";
export * from "./data";
export * from "./types";
export * from "./importers";

View File

@@ -0,0 +1,20 @@
import { ImportType } from "../models";
import { Instructions, Loader } from "./data";
/** Mechanisms that load data into the importer. */
export type DataLoader = (typeof Loader)[keyof typeof Loader];
export type InstructionLink = (typeof Instructions)[keyof typeof Instructions];
/** Mechanisms that load data into the importer. */
export type ImporterMetadata = {
/** Identifies the importer */
type: ImportType;
/** Identifies the instructions for the importer; this defaults to `unique`. */
instructions?: InstructionLink;
/** Describes the strategies used to obtain imported data */
loaders: DataLoader[];
};

View File

@@ -6,7 +6,7 @@ export interface ImportOption {
export const featuredImportOptions = [
{ id: "bitwardenjson", name: "Bitwarden (json)" },
{ id: "bitwardencsv", name: "Bitwarden (csv)" },
{ id: "chromecsv", name: "Chrome (csv)" },
{ id: "chromecsv", name: "Chrome" },
{ id: "dashlanecsv", name: "Dashlane (csv)" },
{ id: "firefoxcsv", name: "Firefox (csv)" },
{ id: "keepass2xml", name: "KeePass 2 (xml)" },
@@ -46,9 +46,10 @@ export const regularImportOptions = [
{ id: "ascendocsv", name: "Ascendo DataVault (csv)" },
{ id: "meldiumcsv", name: "Meldium (csv)" },
{ id: "passkeepcsv", name: "PassKeep (csv)" },
{ id: "edgecsv", name: "Edge (csv)" },
{ id: "operacsv", name: "Opera (csv)" },
{ id: "vivaldicsv", name: "Vivaldi (csv)" },
{ id: "edgecsv", name: "Edge" },
{ id: "operacsv", name: "Opera" },
{ id: "vivaldicsv", name: "Vivaldi" },
{ id: "bravecsv", name: "Brave" },
{ id: "gnomejson", name: "GNOME Passwords and Keys/Seahorse (json)" },
{ id: "blurcsv", name: "Blur (csv)" },
{ id: "passwordagentcsv", name: "Password Agent (csv)" },

View File

@@ -1,11 +1,14 @@
// 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
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";
@@ -13,6 +16,10 @@ export abstract class ImportServiceAbstraction {
featuredImportOptions: readonly ImportOption[];
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,14 +1,20 @@
// 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 { MockSdkService } from "@bitwarden/common/platform/spec/mock-sdk.service";
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";
@@ -19,6 +25,8 @@ 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";
@@ -35,8 +43,8 @@ describe("ImportService", () => {
let encryptService: MockProxy<EncryptService>;
let pinService: MockProxy<PinServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let sdkService: MockSdkService;
let restrictedItemTypesService: MockProxy<RestrictedItemTypesService>;
let systemServiceProvider: MockProxy<SystemServiceProvider>;
beforeEach(() => {
cipherService = mock<CipherService>();
@@ -47,9 +55,20 @@ describe("ImportService", () => {
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
pinService = mock<PinServiceAbstraction>();
sdkService = new MockSdkService();
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,
@@ -60,8 +79,8 @@ describe("ImportService", () => {
encryptService,
pinService,
accountService,
sdkService,
restrictedItemTypesService,
systemServiceProvider,
);
});
@@ -249,6 +268,170 @@ 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 exclude chromium loader when feature flag is disabled", async () => {
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
featureFlagSubject.next(false);
const metadataPromise = firstValueFrom(importService.metadata$(typeSubject));
typeSubject.next(testType);
const result = await metadataPromise;
expect(result.loaders).not.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 feature flag changes", async () => {
const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader
const emissions: ImporterMetadata[] = [];
const subscription = importService.metadata$(typeSubject).subscribe((metadata) => {
emissions.push(metadata);
});
typeSubject.next(testType);
featureFlagSubject.next(true);
// Wait for emissions
await new Promise((resolve) => setTimeout(resolve, 0));
expect(emissions).toHaveLength(2);
expect(emissions[0].loaders).not.toContain(Loader.chromium);
expect(emissions[1].loaders).toContain(Loader.chromium);
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 { firstValueFrom, map } from "rxjs";
import { combineLatest, firstValueFrom, map, 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
@@ -10,6 +10,7 @@ import {
CollectionView,
} from "@bitwarden/admin-console/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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";
@@ -17,8 +18,9 @@ import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/reque
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 { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.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 } 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";
@@ -95,6 +97,7 @@ import {
PasswordDepot17XmlImporter,
} from "../importers";
import { Importer } from "../importers/importer";
import { ImporterMetadata, Importers, Loader } from "../metadata";
import {
featuredImportOptions,
ImportOption,
@@ -104,12 +107,15 @@ 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,
@@ -120,14 +126,42 @@ export class ImportService implements ImportServiceAbstraction {
private encryptService: EncryptService,
private pinService: PinServiceAbstraction,
private accountService: AccountService,
private sdkService: SdkService,
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 browserEnabled$ = this.system.configService.getFeatureFlag$(
FeatureFlag.UseChromiumImporter,
);
const client = this.system.environment.getClientType();
const capabilities$ = combineLatest([type$, browserEnabled$]).pipe(
map(([type, enabled]) => {
let loaders = availableLoaders(type, client);
if (!enabled) {
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,
@@ -260,6 +294,7 @@ export class ImportService implements ImportServiceAbstraction {
case "chromecsv":
case "operacsv":
case "vivaldicsv":
case "bravecsv":
return new ChromeCsvImporter();
case "firefoxcsv":
return new FirefoxCsvImporter();

View File

@@ -0,0 +1,60 @@
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]);
});
});
});

19
libs/importer/src/util.ts Normal file
View File

@@ -0,0 +1,19 @@
import { ClientType } from "@bitwarden/client-type";
import { LoaderAvailability, Importers } from "./metadata";
import { ImportType } from "./models";
/** Lookup the loaders supported by a specific client.
* WARNING: this method does not supply metadata for every import type.
* @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)) {
return undefined;
}
const capabilities = Importers[type]?.loaders ?? [];
const available = capabilities.filter((loader) => LoaderAvailability[loader].includes(client));
return available;
}