diff --git a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs index 9aa2cea6e5e..ea723291fe3 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs @@ -24,12 +24,13 @@ pub fn get_supported_importers( let installed_browsers = T::get_installed_browsers().unwrap_or_default(); const IMPORTERS: &[(&str, &str)] = &[ + ("arccsv", "Arc"), + ("bravecsv", "Brave"), ("chromecsv", "Chrome"), ("chromiumcsv", "Chromium"), - ("bravecsv", "Brave"), + ("edgecsv", "Microsoft Edge"), ("operacsv", "Opera"), ("vivaldicsv", "Vivaldi"), - ("edgecsv", "Microsoft Edge"), ]; let supported: HashSet<&'static str> = @@ -91,6 +92,7 @@ mod tests { let map = get_supported_importers::(); let expected: HashSet = HashSet::from([ + "arccsv".to_string(), "chromecsv".to_string(), "chromiumcsv".to_string(), "bravecsv".to_string(), @@ -113,6 +115,7 @@ mod tests { fn macos_specific_loaders_match_const_array() { let map = get_supported_importers::(); let ids = [ + "arccsv", "chromecsv", "chromiumcsv", "bravecsv", diff --git a/libs/importer/src/components/chrome/import-chrome.component.ts b/libs/importer/src/components/chrome/import-chrome.component.ts index 5467b08ee61..420a95bca5e 100644 --- a/libs/importer/src/components/chrome/import-chrome.component.ts +++ b/libs/importer/src/components/chrome/import-chrome.component.ts @@ -195,6 +195,8 @@ export class ImportChromeComponent implements OnInit, OnDestroy { return "Brave"; } else if (format === "vivaldicsv") { return "Vivaldi"; + } else if (format === "arccsv") { + return "Arc"; } return "Chrome"; } diff --git a/libs/importer/src/importers/arc-csv-importer.spec.ts b/libs/importer/src/importers/arc-csv-importer.spec.ts new file mode 100644 index 00000000000..3ecd3de02bf --- /dev/null +++ b/libs/importer/src/importers/arc-csv-importer.spec.ts @@ -0,0 +1,139 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; + +import { ArcCsvImporter } from "./arc-csv-importer"; +import { data as missingNameAndUrlData } from "./spec-data/arc-csv/missing-name-and-url-data.csv"; +import { data as missingNameWithUrlData } from "./spec-data/arc-csv/missing-name-with-url-data.csv"; +import { data as passwordWithNoteData } from "./spec-data/arc-csv/password-with-note-data.csv"; +import { data as simplePasswordData } from "./spec-data/arc-csv/simple-password-data.csv"; +import { data as subdomainData } from "./spec-data/arc-csv/subdomain-data.csv"; +import { data as urlWithWwwData } from "./spec-data/arc-csv/url-with-www-data.csv"; + +const CipherData = [ + { + title: "should parse password", + csv: simplePasswordData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://example.com/", + }), + ], + }), + notes: null, + type: 1, + }), + }, + { + title: "should parse password with note", + csv: passwordWithNoteData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://example.com/", + }), + ], + }), + notes: "This is a test note", + type: 1, + }), + }, + { + title: "should strip www. prefix from name", + csv: urlWithWwwData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://www.example.com/", + }), + ], + }), + notes: null, + type: 1, + }), + }, + { + title: "should extract name from URL when name is missing", + csv: missingNameWithUrlData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://example.com/login", + }), + ], + }), + notes: null, + type: 1, + }), + }, + { + title: "should use -- as name when both name and URL are missing", + csv: missingNameAndUrlData, + expected: Object.assign(new CipherView(), { + name: "--", + login: Object.assign(new LoginView(), { + username: null, + password: "password123", + uris: null, + }), + notes: null, + type: 1, + }), + }, + { + title: "should preserve subdomain in name", + csv: subdomainData, + expected: Object.assign(new CipherView(), { + name: "login.example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://login.example.com/auth", + }), + ], + }), + notes: null, + type: 1, + }), + }, +]; + +describe("Arc CSV Importer", () => { + CipherData.forEach((data) => { + it(data.title, async () => { + jest.useFakeTimers().setSystemTime(data.expected.creationDate); + const importer = new ArcCsvImporter(); + const result = await importer.parse(data.csv); + expect(result != null).toBe(true); + expect(result.ciphers.length).toBeGreaterThan(0); + + const cipher = result.ciphers.shift(); + let property: keyof typeof data.expected; + for (property in data.expected) { + if (Object.prototype.hasOwnProperty.call(data.expected, property)) { + expect(Object.prototype.hasOwnProperty.call(cipher, property)).toBe(true); + expect(cipher[property]).toEqual(data.expected[property]); + } + } + }); + }); +}); diff --git a/libs/importer/src/importers/arc-csv-importer.ts b/libs/importer/src/importers/arc-csv-importer.ts new file mode 100644 index 00000000000..eb262717009 --- /dev/null +++ b/libs/importer/src/importers/arc-csv-importer.ts @@ -0,0 +1,30 @@ +import { ImportResult } from "../models/import-result"; + +import { BaseImporter } from "./base-importer"; +import { Importer } from "./importer"; + +export class ArcCsvImporter extends BaseImporter implements Importer { + parse(data: string): Promise { + const result = new ImportResult(); + const results = this.parseCsv(data, true); + if (results == null) { + result.success = false; + return Promise.resolve(result); + } + + results.forEach((value) => { + const cipher = this.initLoginCipher(); + const url = this.getValueOrDefault(value.url); + cipher.name = this.getValueOrDefault(this.nameFromUrl(url) ?? "", "--"); + 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); + }); + + result.success = true; + return Promise.resolve(result); + } +} diff --git a/libs/importer/src/importers/index.ts b/libs/importer/src/importers/index.ts index a1e2c46868f..8f9eb923180 100644 --- a/libs/importer/src/importers/index.ts +++ b/libs/importer/src/importers/index.ts @@ -1,3 +1,4 @@ +export { ArcCsvImporter } from "./arc-csv-importer"; export { AscendoCsvImporter } from "./ascendo-csv-importer"; export { AvastCsvImporter, AvastJsonImporter } from "./avast"; export { AviraCsvImporter } from "./avira-csv-importer"; diff --git a/libs/importer/src/importers/spec-data/arc-csv/missing-name-and-url-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/missing-name-and-url-data.csv.ts new file mode 100644 index 00000000000..74170cb57bf --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/missing-name-and-url-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +,,,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/missing-name-with-url-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/missing-name-with-url-data.csv.ts new file mode 100644 index 00000000000..f0271009099 --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/missing-name-with-url-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +,https://example.com/login,user@example.com,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/password-with-note-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/password-with-note-data.csv.ts new file mode 100644 index 00000000000..bf9d218f01b --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/password-with-note-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +example.com,https://example.com/,user@example.com,password123,This is a test note`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/simple-password-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/simple-password-data.csv.ts new file mode 100644 index 00000000000..695b9d10785 --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/simple-password-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +example.com,https://example.com/,user@example.com,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/subdomain-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/subdomain-data.csv.ts new file mode 100644 index 00000000000..bde4c3282af --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/subdomain-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +login.example.com,https://login.example.com/auth,user@example.com,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/url-with-www-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/url-with-www-data.csv.ts new file mode 100644 index 00000000000..17bb1f587e6 --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/url-with-www-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +www.example.com,https://www.example.com/,user@example.com,password123,`; diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts index 22a4f63b248..a4c77483be6 100644 --- a/libs/importer/src/models/import-options.ts +++ b/libs/importer/src/models/import-options.ts @@ -46,6 +46,7 @@ export const regularImportOptions = [ { id: "ascendocsv", name: "Ascendo DataVault (csv)" }, { id: "meldiumcsv", name: "Meldium (csv)" }, { id: "passkeepcsv", name: "PassKeep (csv)" }, + { id: "arccsv", name: "Arc" }, { id: "edgecsv", name: "Edge" }, { id: "operacsv", name: "Opera" }, { id: "vivaldicsv", name: "Vivaldi" }, diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index bd0a9eb0d4d..38c399eb200 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -31,6 +31,7 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res import { KeyService } from "@bitwarden/key-management"; import { + ArcCsvImporter, AscendoCsvImporter, AvastCsvImporter, AvastJsonImporter, @@ -256,6 +257,8 @@ export class ImportService implements ImportServiceAbstraction { return new PadlockCsvImporter(); case "keepass2xml": return new KeePass2XmlImporter(); + case "arccsv": + return new ArcCsvImporter(); case "edgecsv": case "chromecsv": case "operacsv":