Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,144 @@
{
"name": "@suaveplan/password-formats",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal import/export layer for common password-manager export formats — Apple Passwords CSV, Bitwarden JSON, Chrome CSV, KeePass XML",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./detect": {
"import": {
"types": "./dist/core/detector/format-detector.d.ts",
"default": "./dist/core/detector/format-detector.js"
},
"require": {
"types": "./dist/core/detector/format-detector.d.ts",
"default": "./dist/core/detector/format-detector.js"
}
},
"./parse": {
"import": {
"types": "./dist/core/parse/parse.d.ts",
"default": "./dist/core/parse/parse.js"
},
"require": {
"types": "./dist/core/parse/parse.d.ts",
"default": "./dist/core/parse/parse.js"
}
},
"./parsers/apple": {
"import": {
"types": "./dist/parsers/apple-passwords/apple-passwords.d.ts",
"default": "./dist/parsers/apple-passwords/apple-passwords.js"
},
"require": {
"types": "./dist/parsers/apple-passwords/apple-passwords.d.ts",
"default": "./dist/parsers/apple-passwords/apple-passwords.js"
}
},
"./parsers/bitwarden": {
"import": {
"types": "./dist/parsers/bitwarden/bitwarden.d.ts",
"default": "./dist/parsers/bitwarden/bitwarden.js"
},
"require": {
"types": "./dist/parsers/bitwarden/bitwarden.d.ts",
"default": "./dist/parsers/bitwarden/bitwarden.js"
}
},
"./parsers/chrome": {
"import": {
"types": "./dist/parsers/chrome/chrome.d.ts",
"default": "./dist/parsers/chrome/chrome.js"
},
"require": {
"types": "./dist/parsers/chrome/chrome.d.ts",
"default": "./dist/parsers/chrome/chrome.js"
}
},
"./parsers/keepass": {
"import": {
"types": "./dist/parsers/keepass/keepass.d.ts",
"default": "./dist/parsers/keepass/keepass.js"
},
"require": {
"types": "./dist/parsers/keepass/keepass.d.ts",
"default": "./dist/parsers/keepass/keepass.js"
}
},
"./export": {
"import": {
"types": "./dist/export/csv-export.d.ts",
"default": "./dist/export/csv-export.js"
},
"require": {
"types": "./dist/export/csv-export.d.ts",
"default": "./dist/export/csv-export.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/password-formats-error.d.ts",
"default": "./dist/errors/password-formats-error.js"
},
"require": {
"types": "./dist/errors/password-formats-error.d.ts",
"default": "./dist/errors/password-formats-error.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"fast-xml-parser": "catalog:parsing",
"fflate": "catalog:parsing",
"papaparse": "catalog:parsing",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@types/papaparse": "catalog:parsing",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,72 @@
# format-detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a single `detect()` function that inspects the raw content of a password manager export file (and an optional filename hint) to identify which supported format it belongs to. This enables the `parse()` function to automatically dispatch to the correct parser without requiring callers to specify the format manually. The detector uses content-based heuristics rather than relying solely on file extensions, making it robust against mislabelled or extension-less files.
## Features
- Content-first detection — inspects actual bytes/characters rather than relying purely on file extensions
- KeePass XML detection via `<KeePassFile` start tag
- Bitwarden JSON detection via `"items"` / `"encrypted"` keys; throws `ENCRYPTED_NOT_SUPPORTED` for encrypted vaults
- Apple Passwords CSV detection via `OTPAuth` + `Title` header columns
- Chrome CSV detection via `name`, `url`, `username`, `password` header columns
- Returns `"unknown"` for unrecognised formats rather than throwing
- Accepts both `string` and `Uint8Array` inputs
## Usage: Basic Example
```ts
import { detect } from "@suaveplan/password-formats/detect";
const format = detect(csvContent, "passwords.csv");
console.log(format); // "apple" | "chrome" | "bitwarden" | "keepass" | "unknown"
```
## Usage: Advanced Example
```ts
import { detect } from "@suaveplan/password-formats/detect";
import { PasswordFormatsError, PASSWORD_FORMATS_CODES } from "@suaveplan/password-formats/errors";
async function importFile(name: string, bytes: Uint8Array): Promise<void> {
let format: string;
try {
format = detect(bytes, name);
} catch (err) {
if (err instanceof PasswordFormatsError && err.code === PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED) {
console.error("Please export an unencrypted Bitwarden vault before importing.");
return;
}
throw err;
}
if (format === "unknown") {
console.warn("Unrecognised format — defaulting to manual selection");
}
}
```
## API Reference
### `detect`
```ts
function detect(input: string | Uint8Array, filename?: string): PasswordFormat
```
**Parameters:**
- `input` — File contents as a UTF-8 string or raw byte array.
- `filename` — Optional filename (e.g. `"export.json"`). Used as a secondary hint alongside content inspection.
**Returns:** One of `"apple"`, `"bitwarden"`, `"chrome"`, `"keepass"`, or `"unknown"`.
**Throws:** `PasswordFormatsError` with code `PASSWORD_FORMATS.ENCRYPTED_NOT_SUPPORTED` when the input is a Bitwarden encrypted export.
## Implementation Notes
Detection order matters: KeePass XML is checked first because `<KeePassFile` is a highly specific marker. JSON detection comes next and is content-driven — a valid JSON object with an `"items"` key is classified as Bitwarden regardless of file extension. CSV header inspection is last because CSV is the most ambiguous format. The `"unknown"` fallback is intentional: callers can handle it gracefully rather than receiving an unexpected exception for unrecognised files.

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import { detect } from "./format-detector.js";
describe("detect", () => {
it("detects apple format from OTPAuth header", { timeout: 5000 }, () => {
const csv =
"Title,URL,Username,Password,Notes,OTPAuth\nMy Bank,https://bank.com,user,pass,,";
expect(detect(csv)).toBe("apple");
});
it("detects apple format case-insensitively for OTPAuth", {
timeout: 5000,
}, () => {
const csv =
"title,url,username,password,notes,otpauth\nMy Bank,https://bank.com,user,pass,,";
expect(detect(csv)).toBe("apple");
});
it("detects chrome format from name/url/username/password header", {
timeout: 5000,
}, () => {
const csv =
"name,url,username,password\nGmail,https://gmail.com,user,pass";
expect(detect(csv)).toBe("chrome");
});
it("detects bitwarden format from JSON with items key", {
timeout: 5000,
}, () => {
const json = JSON.stringify({ encrypted: false, items: [] });
expect(detect(json)).toBe("bitwarden");
});
it("detects bitwarden format from JSON with encrypted=false", {
timeout: 5000,
}, () => {
const json = JSON.stringify({ encrypted: false });
expect(detect(json)).toBe("bitwarden");
});
it("detects bitwarden via filename hint", { timeout: 5000 }, () => {
const json = JSON.stringify({ encrypted: false, items: [] });
expect(detect(json, "bitwarden_export.json")).toBe("bitwarden");
});
it("throws ENCRYPTED_NOT_SUPPORTED for encrypted bitwarden", {
timeout: 5000,
}, () => {
const json = JSON.stringify({ encrypted: true });
expect(() => detect(json)).toThrow(PasswordFormatsError);
expect(() => detect(json)).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED,
})
);
});
it("detects keepass format from KeePassFile tag", { timeout: 5000 }, () => {
const xml = `<?xml version="1.0"?>\n<KeePassFile><Root></Root></KeePassFile>`;
expect(detect(xml)).toBe("keepass");
});
it("detects keepass format from content starting with <KeePassFile", {
timeout: 5000,
}, () => {
const xml = "<KeePassFile><Root></Root></KeePassFile>";
expect(detect(xml)).toBe("keepass");
});
it("returns unknown for unrecognised format", { timeout: 5000 }, () => {
expect(detect("random content that does not match anything")).toBe(
"unknown"
);
});
it("accepts Uint8Array input", { timeout: 5000 }, () => {
const csv =
"Title,URL,Username,Password,Notes,OTPAuth\nMy Bank,https://bank.com,user,pass,,";
const bytes = new TextEncoder().encode(csv);
expect(detect(bytes)).toBe("apple");
});
it("uses filename as hint for json detection", { timeout: 5000 }, () => {
const json = JSON.stringify({
encrypted: false,
items: [{ name: "test", type: 1 }],
});
expect(detect(json, "export.json")).toBe("bitwarden");
});
it("returns unknown for JSON object without items or encrypted keys", {
timeout: 5000,
}, () => {
// JSON object with neither 'items' nor 'encrypted' — falls to unknown
const json = JSON.stringify({ something: "else", foo: 42 });
expect(detect(json)).toBe("unknown");
});
it("returns unknown for JSON array", { timeout: 5000 }, () => {
// JSON array is not a bitwarden export
const json = JSON.stringify([{ name: "item" }]);
expect(detect(json)).toBe("unknown");
});
it("handles .json extension with invalid JSON content", {
timeout: 5000,
}, () => {
// .json extension but not parseable JSON
expect(detect("not json at all {{{", "passwords.json")).toBe("unknown");
});
it("returns unknown for empty string input", { timeout: 5000 }, () => {
// Empty input covers the || '' fallback branch in firstLine extraction
expect(detect("")).toBe("unknown");
});
});

View File

@@ -0,0 +1,82 @@
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import type { PasswordFormat } from "../../types/password-formats.types.js";
/**
* Detect the password manager export format from file content and optional filename.
*
* @param input - Raw file content as a string or Uint8Array.
* @param filename - Optional filename used as a hint for format detection.
* @returns The detected `PasswordFormat`.
* @throws {PasswordFormatsError} With code `ENCRYPTED_NOT_SUPPORTED` when a Bitwarden encrypted vault is detected.
*/
export function detect(
input: string | Uint8Array,
filename?: string
): PasswordFormat {
const text =
typeof input === "string" ? input : new TextDecoder().decode(input);
const trimmed = text.trimStart();
const lower = filename?.toLowerCase() ?? "";
// KeePass XML detection — check content first (most specific)
if (
trimmed.startsWith("<KeePassFile") ||
trimmed.startsWith("<keepass") ||
trimmed.includes("<KeePassFile")
) {
return "keepass";
}
// JSON-based formats
if (lower.endsWith(".json") || trimmed.startsWith("{")) {
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
// Not valid JSON — fall through
}
if (
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed)
) {
const obj = parsed as Record<string, unknown>;
if (obj["encrypted"] === true) {
throw new PasswordFormatsError(
"Bitwarden encrypted vaults are not supported. Please export an unencrypted vault.",
PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED
);
}
if ("items" in obj || obj["encrypted"] === false) {
return "bitwarden";
}
}
}
// CSV-based formats — inspect first line as header
// String.prototype.split always returns ≥1 element; [0] is always defined.
// The `|| ""` fallback satisfies noUncheckedIndexedAccess TypeScript config.
const firstLine = trimmed.split(/\r?\n/)[0] || "";
const headerLower = firstLine.toLowerCase();
// Apple Passwords CSV: must have OTPAuth and Title columns
if (headerLower.includes("otpauth") && headerLower.includes("title")) {
return "apple";
}
// Chrome CSV: name,url,username,password
if (
headerLower.includes("name") &&
headerLower.includes("url") &&
headerLower.includes("username") &&
headerLower.includes("password")
) {
return "chrome";
}
return "unknown";
}

View File

@@ -0,0 +1 @@
export { detect } from "./format-detector";

View File

@@ -0,0 +1,3 @@
export { detect } from "./detector";
export { parse } from "./parse";
export { validate } from "./validator";

View File

@@ -0,0 +1 @@
export { parse } from "./parse";

View File

@@ -0,0 +1,75 @@
# parse
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the primary entry point for parsing password manager export files in the `@suaveplan/password-formats` package. The `parse()` function accepts raw file content as a string or `Uint8Array`, optionally accepts a format hint, and dispatches to the appropriate format-specific parser after auto-detecting the format when none is specified. It instruments every parse operation with OpenTelemetry counters and histograms so operators can monitor import volume and latency in production.
## Features
- Auto-detects format using `detect()` when `format` is not provided
- Dispatches to `parseApplePasswordsCsv`, `parseBitwardenJson`, `parseChromeCsv`, or `parseKeePassXml`
- Emits OTel counter `password_formats.parse.total{format}` on each call
- Records OTel histogram `password_formats.parse.duration_ms{format}` for latency tracking
- Wraps execution in an OTel span `password_formats.parse` with `format` and `entry_count` attributes
- Returns a `Promise<PasswordEntry[]>` for consistent async interface across all callers
- Accepts both `string` and `Uint8Array` inputs (decoded as UTF-8)
## Usage: Basic Example
```ts
import { parse } from "@suaveplan/password-formats/parse";
const entries = await parse(csvContent);
console.log(entries.length); // Number of parsed entries
```
## Usage: Advanced Example
```ts
import { parse } from "@suaveplan/password-formats/parse";
// Explicit format override + parse options
const entries = await parse(jsonContent, "bitwarden", {
skipInvalid: true,
maxEntries: 500,
});
// Pass a Uint8Array from a file upload
const bytes = new Uint8Array(await file.arrayBuffer());
const entries2 = await parse(bytes, undefined, { skipInvalid: false });
```
## API Reference
### `parse`
```ts
async function parse(
input: string | Uint8Array,
format?: PasswordFormat,
options?: ParseOptions,
): Promise<PasswordEntry[]>
```
**Parameters:**
- `input` — File content as a UTF-8 string or byte array.
- `format` — Optional format override. If omitted, `detect()` is called automatically.
- `options.skipInvalid` — Skip malformed entries instead of throwing. Default: `false`.
- `options.maxEntries` — Cap on the number of returned entries.
**Returns:** Promise resolving to an array of `PasswordEntry` objects.
**Throws:**
- `PasswordFormatsError` (`INVALID_INPUT`) — `input` is empty.
- `PasswordFormatsError` (`UNSUPPORTED_FORMAT`) — Format is `"unknown"` or unrecognised.
- `PasswordFormatsError` (`ENCRYPTED_NOT_SUPPORTED`) — Bitwarden encrypted vault detected.
- `PasswordFormatsError` (`PARSE_ERROR`) — Malformed content and `skipInvalid` is `false`.
## Implementation Notes
The function is `async` even though the underlying parsers are synchronous. This design choice future-proofs the API for streaming or worker-thread implementations without breaking callers. The OTel SDK no-ops gracefully when no global tracer/meter provider is registered, so the instrumentation has zero overhead in environments that do not configure OTel. Duration is measured with `Date.now()` and recorded in milliseconds to keep the instrumentation dependency surface minimal.

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import { parse } from "./parse.js";
const APPLE_CSV = `Title,URL,Username,Password,Notes,OTPAuth
My Bank,https://bank.com,user@example.com,s3cr3t,Main account,`;
const BITWARDEN_JSON = JSON.stringify({
encrypted: false,
items: [
{
id: "123",
name: "Gmail",
login: {
username: "user@gmail.com",
password: "pass123",
uris: [{ uri: "https://gmail.com" }],
},
type: 1,
},
],
});
const CHROME_CSV =
"name,url,username,password\nGmail,https://gmail.com,test@gmail.com,pass123";
const KEEPASS_XML = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Key>Title</Key><Value>Test Entry</Value></String>
<String><Key>Password</Key><Value>testpass</Value></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
describe("parse", () => {
it("auto-detects apple format", { timeout: 30000 }, async () => {
const entries = await parse(APPLE_CSV);
expect(entries[0].title).toBe("My Bank");
});
it("auto-detects bitwarden format", { timeout: 30000 }, async () => {
const entries = await parse(BITWARDEN_JSON);
expect(entries[0].title).toBe("Gmail");
});
it("auto-detects chrome format", { timeout: 30000 }, async () => {
const entries = await parse(CHROME_CSV);
expect(entries[0].title).toBe("Gmail");
});
it("auto-detects keepass format", { timeout: 30000 }, async () => {
const entries = await parse(KEEPASS_XML);
expect(entries[0].title).toBe("Test Entry");
});
it("parses with explicit format override", { timeout: 30000 }, async () => {
const entries = await parse(APPLE_CSV, "apple");
expect(entries).toHaveLength(1);
});
it("throws INVALID_INPUT for empty string", {
timeout: 30000,
}, async () => {
await expect(parse("")).rejects.toThrow(PasswordFormatsError);
await expect(parse("")).rejects.toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.INVALID_INPUT,
})
);
});
it("throws INVALID_INPUT for whitespace-only string", {
timeout: 30000,
}, async () => {
await expect(parse(" ")).rejects.toThrow(PasswordFormatsError);
await expect(parse(" ")).rejects.toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.INVALID_INPUT,
})
);
});
it("throws UNSUPPORTED_FORMAT for unknown content", {
timeout: 30000,
}, async () => {
await expect(
parse("just some random text without any known format markers")
).rejects.toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.UNSUPPORTED_FORMAT,
})
);
});
it("accepts Uint8Array input", { timeout: 30000 }, async () => {
const bytes = new TextEncoder().encode(APPLE_CSV);
const entries = await parse(bytes);
expect(entries[0].title).toBe("My Bank");
});
it("passes options to parser", { timeout: 30000 }, async () => {
const entries = await parse(APPLE_CSV, "apple", { maxEntries: 0 });
expect(entries).toHaveLength(0);
});
it("throws UNSUPPORTED_FORMAT for unrecognised explicit format", {
timeout: 30000,
}, async () => {
// Cast to PasswordFormat to reach the default branch
await expect(parse(APPLE_CSV, "notaformat" as "apple")).rejects.toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.UNSUPPORTED_FORMAT,
})
);
});
});

View File

@@ -0,0 +1,111 @@
import { metrics, trace } from "@opentelemetry/api";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import { parseApplePasswordsCsv } from "../../parsers/apple-passwords/apple-passwords.js";
import { parseBitwardenJson } from "../../parsers/bitwarden/bitwarden.js";
import { parseChromeCsv } from "../../parsers/chrome/chrome.js";
import { parseKeePassXml } from "../../parsers/keepass/keepass.js";
import type {
ParseOptions,
PasswordEntry,
PasswordFormat,
} from "../../types/password-formats.types.js";
import { detect } from "../detector/format-detector.js";
const tracer = trace.getTracer("@suaveplan/password-formats");
const meter = metrics.getMeter("@suaveplan/password-formats");
const parseCounter = meter.createCounter("password_formats.parse.total", {
description: "Total number of parse calls by format",
});
const parseDuration = meter.createHistogram(
"password_formats.parse.duration_ms",
{
description: "Duration of parse operations in milliseconds",
unit: "ms",
}
);
/**
* Parse a password manager export file into normalized PasswordEntry objects.
*
* If `format` is not provided, the format is auto-detected using `detect()`.
*
* @param input - Raw file content as a string or Uint8Array.
* @param format - Optional format override. Defaults to auto-detection.
* @param options - Parse options.
* @returns Promise resolving to an array of PasswordEntry objects.
* @throws {PasswordFormatsError} With code `INVALID_INPUT` if input is empty.
* @throws {PasswordFormatsError} With code `UNSUPPORTED_FORMAT` if format is unknown.
* @throws {PasswordFormatsError} With code `ENCRYPTED_NOT_SUPPORTED` for encrypted Bitwarden vaults.
* @throws {PasswordFormatsError} With code `PARSE_ERROR` on malformed content.
*/
export async function parse(
input: string | Uint8Array,
format?: PasswordFormat,
options?: ParseOptions
): Promise<PasswordEntry[]> {
return tracer.startActiveSpan("password_formats.parse", async (span) => {
const startMs = Date.now();
try {
if (
!input ||
(typeof input === "string" && input.trim().length === 0)
) {
throw new PasswordFormatsError(
"Input must not be empty",
PASSWORD_FORMATS_CODES.INVALID_INPUT
);
}
const text =
typeof input === "string"
? input
: new TextDecoder().decode(input);
const resolvedFormat = format ?? detect(text);
parseCounter.add(1, { format: resolvedFormat });
let entries: PasswordEntry[];
switch (resolvedFormat) {
case "apple":
entries = parseApplePasswordsCsv(text, options);
break;
case "bitwarden":
entries = parseBitwardenJson(text, options);
break;
case "chrome":
entries = parseChromeCsv(text, options);
break;
case "keepass":
entries = parseKeePassXml(text, options);
break;
case "unknown":
throw new PasswordFormatsError(
"Unable to detect password format. Supported formats: Apple Passwords CSV, Bitwarden JSON, Chrome CSV, KeePass XML.",
PASSWORD_FORMATS_CODES.UNSUPPORTED_FORMAT
);
default:
throw new PasswordFormatsError(
`Unsupported format: ${resolvedFormat as string}`,
PASSWORD_FORMATS_CODES.UNSUPPORTED_FORMAT
);
}
span.setAttribute("format", resolvedFormat);
span.setAttribute("entry_count", entries.length);
return entries;
} finally {
const durationMs = Date.now() - startMs;
parseDuration.record(durationMs, { format: format ?? "auto" });
span.end();
}
});
}

View File

@@ -0,0 +1 @@
export { validate } from "./validate";

View File

@@ -0,0 +1,79 @@
# validate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validates an array of `PasswordEntry` objects against the minimum required field constraints for the `@suaveplan/password-formats` package. After parsing a password manager export, callers can run `validate()` to get a structured report of which entries are well-formed and which are not — useful for building import UIs that show per-entry error messages before committing the import. The validator is intentionally permissive: it only enforces the two fields that are universally required (`title` and `password`), leaving all other fields optional.
## Features
- Validates that `title` is a non-empty string (after trimming)
- Validates that `password` is a non-empty string
- Returns a `ValidationResult` with per-entry `{ index, message }` error tuples
- Reports `validCount` and `invalidCount` for summary display
- Sets `valid: true` only when there are zero errors
- Does not mutate the input array
## Usage: Basic Example
```ts
import { validate } from "@suaveplan/password-formats/parse";
import type { PasswordEntry } from "@suaveplan/password-formats";
const entries: PasswordEntry[] = [
{ title: "Gmail", password: "secret" },
{ title: "", password: "pass" },
];
const result = validate(entries);
console.log(result.valid); // false
console.log(result.validCount); // 1
console.log(result.invalidCount); // 1
console.log(result.errors[0]); // { index: 1, message: "Entry at index 1: title is required..." }
```
## Usage: Advanced Example
```ts
import { parse, validate } from "@suaveplan/password-formats";
async function importWithValidation(content: string): Promise<void> {
const entries = await parse(content);
const result = validate(entries);
if (!result.valid) {
console.error(`Import has ${result.invalidCount} invalid entries:`);
for (const err of result.errors) {
console.error(` Row ${err.index + 1}: ${err.message}`);
}
return;
}
console.log(`All ${result.validCount} entries are valid.`);
}
```
## API Reference
### `validate`
```ts
function validate(entries: PasswordEntry[]): ValidationResult
```
**Parameters:**
- `entries` — Array of `PasswordEntry` objects to validate.
**Returns:** `ValidationResult`
- `valid` — `true` if no errors were found.
- `errors` — Array of `{ index: number; message: string }` tuples, one per violation.
- `validCount` — Number of entries with no errors.
- `invalidCount` — Number of entries with at least one error.
## Implementation Notes
An entry can generate multiple error tuples (e.g. missing both `title` and `password`), but it counts as one invalid entry in `invalidCount`. The `invalidCount` is computed by counting distinct indices in the errors array rather than simply using `errors.length`, ensuring accurate summary counts when multiple rules fire on the same entry. The validator does not attempt deep structural validation of fields like `url` format or ISO 8601 compliance — that is the responsibility of the consuming application.

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PasswordEntry } from "../../types/password-formats.types.js";
import { validate } from "./validate.js";
describe("validate", () => {
it("returns valid=true for entries with title and password", {
timeout: 5000,
}, () => {
const entries: PasswordEntry[] = [
{ title: "Gmail", password: "pass123" },
{ title: "GitHub", password: "ghtoken" },
];
const result = validate(entries);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.validCount).toBe(2);
expect(result.invalidCount).toBe(0);
});
it("returns valid=false for entry with empty title", {
timeout: 5000,
}, () => {
const entries: PasswordEntry[] = [{ title: "", password: "pass123" }];
const result = validate(entries);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].index).toBe(0);
expect(result.errors[0].message).toMatch(/title/);
});
it("returns valid=false for entry with empty password", {
timeout: 5000,
}, () => {
const entries: PasswordEntry[] = [{ title: "Gmail", password: "" }];
const result = validate(entries);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].index).toBe(0);
expect(result.errors[0].message).toMatch(/password/);
});
it("counts valid and invalid entries correctly", { timeout: 5000 }, () => {
const entries: PasswordEntry[] = [
{ title: "Valid", password: "pass" },
{ title: "", password: "pass" },
{ title: "Also Valid", password: "pass2" },
];
const result = validate(entries);
expect(result.validCount).toBe(2);
expect(result.invalidCount).toBe(1);
});
it("reports multiple errors for entry missing both title and password", {
timeout: 5000,
}, () => {
const entries: PasswordEntry[] = [{ title: "", password: "" }];
const result = validate(entries);
expect(result.errors).toHaveLength(2);
expect(result.invalidCount).toBe(1);
});
it("handles whitespace-only title as invalid", { timeout: 5000 }, () => {
const entries: PasswordEntry[] = [{ title: " ", password: "pass" }];
const result = validate(entries);
expect(result.valid).toBe(false);
});
it("handles empty entries array", { timeout: 5000 }, () => {
const result = validate([]);
expect(result.valid).toBe(true);
expect(result.validCount).toBe(0);
expect(result.invalidCount).toBe(0);
});
it("includes correct index in error report", { timeout: 5000 }, () => {
const entries: PasswordEntry[] = [
{ title: "Valid", password: "pass" },
{ title: "", password: "" },
];
const result = validate(entries);
expect(result.errors[0].index).toBe(1);
expect(result.errors[1].index).toBe(1);
});
});

View File

@@ -0,0 +1,45 @@
import type {
PasswordEntry,
ValidationResult,
} from "../../types/password-formats.types.js";
/**
* Validate an array of PasswordEntry objects.
*
* Each entry must have a non-empty `title` string and a non-empty `password` string.
*
* @param entries - Array of PasswordEntry objects to validate.
* @returns A ValidationResult with per-entry error details.
*/
export function validate(entries: PasswordEntry[]): ValidationResult {
const errors: Array<{ index: number; message: string }> = [];
let i = 0;
for (const entry of entries) {
if (!entry.title || entry.title.trim().length === 0) {
errors.push({
index: i,
message: `Entry at index ${i}: title is required and must be non-empty`,
});
}
if (!entry.password || entry.password.length === 0) {
errors.push({
index: i,
message: `Entry at index ${i}: password is required and must be non-empty`,
});
}
i++;
}
const invalidCount = new Set(errors.map((e) => e.index)).size;
const validCount = entries.length - invalidCount;
return {
valid: errors.length === 0,
errors,
validCount,
invalidCount,
};
}

View File

@@ -0,0 +1,5 @@
export {
PASSWORD_FORMATS_CODES,
type PasswordFormatsCode,
PasswordFormatsError,
} from "./password-formats-error";

View File

@@ -0,0 +1,86 @@
# password-formats-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error code registry and custom `PasswordFormatsError` class used throughout the `@suaveplan/password-formats` package. By centralizing all error codes and the error class in one module, callers can discriminate errors with a stable `code` string rather than relying on fragile message-string matching. Every public API in this package throws `PasswordFormatsError` on failure, ensuring a consistent and predictable error surface.
## Features
- `PASSWORD_FORMATS_CODES` — a `const` object of all stable error codes, namespaced under `PASSWORD_FORMATS.*`
- `PasswordFormatsError` — extends `Error`, carries `name = "PasswordFormatsError"` and a `code: string` property
- `PasswordFormatsCode` — union type for all valid code strings, useful for exhaustive `switch` statements
## Usage: Basic Example
```ts
import { PasswordFormatsError, PASSWORD_FORMATS_CODES } from "@suaveplan/password-formats/errors";
try {
throw new PasswordFormatsError("Encrypted vaults are not supported", PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED);
} catch (err) {
if (err instanceof PasswordFormatsError) {
console.error(err.code); // "PASSWORD_FORMATS.ENCRYPTED_NOT_SUPPORTED"
}
}
```
## Usage: Advanced Example
```ts
import { PasswordFormatsError, PASSWORD_FORMATS_CODES, type PasswordFormatsCode } from "@suaveplan/password-formats/errors";
function handleError(err: unknown): void {
if (!(err instanceof PasswordFormatsError)) throw err;
const actions: Record<PasswordFormatsCode, () => void> = {
[PASSWORD_FORMATS_CODES.UNSUPPORTED_FORMAT]: () => console.warn("Unknown format — try a different file"),
[PASSWORD_FORMATS_CODES.PARSE_ERROR]: () => console.error("Malformed input"),
[PASSWORD_FORMATS_CODES.INVALID_INPUT]: () => console.error("Input is null or empty"),
[PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED]: () => console.error("Decrypt before importing"),
};
(actions[err.code as PasswordFormatsCode] ?? (() => { throw err; }))();
}
```
## API Reference
### `PASSWORD_FORMATS_CODES`
```ts
const PASSWORD_FORMATS_CODES: {
UNSUPPORTED_FORMAT: "PASSWORD_FORMATS.UNSUPPORTED_FORMAT";
PARSE_ERROR: "PASSWORD_FORMATS.PARSE_ERROR";
INVALID_INPUT: "PASSWORD_FORMATS.INVALID_INPUT";
ENCRYPTED_NOT_SUPPORTED: "PASSWORD_FORMATS.ENCRYPTED_NOT_SUPPORTED";
}
```
Stable string constants for all error conditions in the package.
### `PasswordFormatsError`
```ts
class PasswordFormatsError extends Error {
readonly code: string;
constructor(message: string, code: string): PasswordFormatsError;
}
```
- `message` — human-readable description of what went wrong
- `code` — one of the `PASSWORD_FORMATS_CODES` values
### `PasswordFormatsCode`
```ts
type PasswordFormatsCode = "PASSWORD_FORMATS.UNSUPPORTED_FORMAT" | "PASSWORD_FORMATS.PARSE_ERROR" | ...;
```
Union type over all valid `PASSWORD_FORMATS_CODES` values.
## Implementation Notes
The error class deliberately does not extend a `@suaveplan/error` base class because `@suaveplan/password-formats` is a leaf package with no `@suaveplan/*` runtime dependencies. This keeps the dependency graph minimal and avoids circular dependencies. The `code` property mirrors the pattern established by `SuaveplanError` so that error-handling middleware in consuming applications can treat both interchangeably when checking `.code`.

View File

@@ -0,0 +1,28 @@
/**
* Error codes and custom error class for the password-formats package.
*/
/** Stable error code registry for password-formats errors. */
export const PASSWORD_FORMATS_CODES = {
UNSUPPORTED_FORMAT: "PASSWORD_FORMATS.UNSUPPORTED_FORMAT",
PARSE_ERROR: "PASSWORD_FORMATS.PARSE_ERROR",
INVALID_INPUT: "PASSWORD_FORMATS.INVALID_INPUT",
ENCRYPTED_NOT_SUPPORTED: "PASSWORD_FORMATS.ENCRYPTED_NOT_SUPPORTED",
} as const;
export type PasswordFormatsCode =
(typeof PASSWORD_FORMATS_CODES)[keyof typeof PASSWORD_FORMATS_CODES];
/**
* Custom error class for all password-formats operations.
* Carries a stable `code` string from `PASSWORD_FORMATS_CODES`.
*/
export class PasswordFormatsError extends Error {
readonly code: string;
constructor(message: string, code: string) {
super(message);
this.name = "PasswordFormatsError";
this.code = code;
}
}

View File

@@ -0,0 +1,75 @@
# csv-export
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exports an array of normalized `PasswordEntry` objects to a universal CSV format that can be imported into most password managers or spreadsheet tools. This module is the reverse operation of the format-specific parsers — it produces a consistent, predictable CSV regardless of the original source format. The output CSV uses a standardized seven-column schema that balances completeness with broad import compatibility.
## Features
- Uses `papaparse.unparse()` for standards-compliant CSV generation including proper quoting
- Exports seven columns: `title`, `url`, `username`, `password`, `notes`, `folder`, `tags`
- Tags array is serialized as a comma-separated string within the column
- Missing optional fields are exported as empty strings (not omitted)
- Always includes a header row
## Usage: Basic Example
```ts
import { exportToCsv } from "@suaveplan/password-formats/export";
import type { PasswordEntry } from "@suaveplan/password-formats";
const entries: PasswordEntry[] = [
{ title: "Gmail", password: "secret", url: "https://gmail.com", username: "me@gmail.com" },
];
const csv = exportToCsv(entries);
console.log(csv);
// title,url,username,password,notes,folder,tags
// Gmail,https://gmail.com,me@gmail.com,secret,,,
```
## Usage: Advanced Example
```ts
import { parse, exportToCsv } from "@suaveplan/password-formats";
// Round-trip: parse Bitwarden export → normalize → re-export as universal CSV
const entries = await parse(bitwardenJson, "bitwarden");
const csv = exportToCsv(entries);
// Save or transmit the CSV
const blob = new Blob([csv], { type: "text/csv" });
```
## API Reference
### `exportToCsv`
```ts
function exportToCsv(entries: PasswordEntry[]): string
```
**Parameters:**
- `entries` — Array of `PasswordEntry` objects to export.
**Returns:** A CSV string with a header row and one data row per entry.
**Column mapping:**
| Column | Source field | Notes |
|--------|-------------|-------|
| `title` | `entry.title` | Required field |
| `url` | `entry.url` | Empty string if absent |
| `username` | `entry.username` | Empty string if absent |
| `password` | `entry.password` | Required field |
| `notes` | `entry.notes` | Empty string if absent |
| `folder` | `entry.folder` | Empty string if absent |
| `tags` | `entry.tags` | Comma-joined, empty string if absent |
## Implementation Notes
The `tags` field uses a comma-separated representation within a single CSV column. When a tag itself contains a comma, `papaparse` will correctly quote the entire cell value. Fields not included in the export schema — such as `id`, `favorite`, `type`, `createdAt`, `updatedAt`, and `customFields` — are intentionally omitted to keep the output universally importable. Consumers requiring those fields should use the full `PasswordEntry` object array in memory rather than CSV serialization.

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PasswordEntry } from "../types/password-formats.types.js";
import { exportToCsv } from "./csv-export.js";
describe("exportToCsv", () => {
it("exports a single entry with all fields", { timeout: 5000 }, () => {
const entries: PasswordEntry[] = [
{
title: "Gmail",
password: "secret",
url: "https://gmail.com",
username: "me@gmail.com",
notes: "work",
folder: "Email",
tags: ["work", "important"],
},
];
const csv = exportToCsv(entries);
expect(csv).toContain("title");
expect(csv).toContain("Gmail");
expect(csv).toContain("https://gmail.com");
expect(csv).toContain("me@gmail.com");
expect(csv).toContain("secret");
expect(csv).toContain("work");
expect(csv).toContain("Email");
expect(csv).toContain("work,important");
});
it("includes header row with all 7 columns", { timeout: 5000 }, () => {
const csv = exportToCsv([]);
const header = csv.split("\n")[0];
expect(header).toContain("title");
expect(header).toContain("url");
expect(header).toContain("username");
expect(header).toContain("password");
expect(header).toContain("notes");
expect(header).toContain("folder");
expect(header).toContain("tags");
});
it("exports empty fields as empty strings", { timeout: 5000 }, () => {
const entries: PasswordEntry[] = [{ title: "Test", password: "pass" }];
const csv = exportToCsv(entries);
const lines = csv.trim().split("\n");
expect(lines).toHaveLength(2); // header + 1 data row
});
it("exports multiple entries", { timeout: 5000 }, () => {
const entries: PasswordEntry[] = [
{ title: "Gmail", password: "pass1" },
{ title: "GitHub", password: "pass2" },
{ title: "Twitter", password: "pass3" },
];
const csv = exportToCsv(entries);
const lines = csv.trim().split("\n");
expect(lines).toHaveLength(4); // header + 3 data rows
});
it("exports tags as comma-separated string", { timeout: 5000 }, () => {
const entries: PasswordEntry[] = [
{ title: "Test", password: "pass", tags: ["tag1", "tag2", "tag3"] },
];
const csv = exportToCsv(entries);
expect(csv).toContain("tag1,tag2,tag3");
});
it("exports empty tags as empty string", { timeout: 5000 }, () => {
const entries: PasswordEntry[] = [{ title: "Test", password: "pass" }];
const csv = exportToCsv(entries);
// Should not throw and should produce valid CSV
const lines = csv.trim().split("\n");
expect(lines[1]).toContain("Test");
});
it("returns only header for empty entries array", { timeout: 5000 }, () => {
const csv = exportToCsv([]);
const lines = csv.trim().split("\n");
expect(lines).toHaveLength(1);
expect(lines[0]).toContain("title");
});
});

View File

@@ -0,0 +1,52 @@
import Papa from "papaparse";
import type { PasswordEntry } from "../types/password-formats.types.js";
interface CsvRow {
title: string;
url: string;
username: string;
password: string;
notes: string;
folder: string;
tags: string;
}
/**
* Export an array of PasswordEntry objects to a universal CSV string.
*
* Columns: title, url, username, password, notes, folder, tags
*
* @param entries - Array of PasswordEntry objects to export.
* @returns A CSV string with a header row.
*/
export function exportToCsv(entries: PasswordEntry[]): string {
const columns: (keyof CsvRow)[] = [
"title",
"url",
"username",
"password",
"notes",
"folder",
"tags",
];
if (entries.length === 0) {
// PapaParse returns empty string for empty data — always emit header
return columns.join(",");
}
const rows: CsvRow[] = entries.map((entry) => ({
title: entry.title,
url: entry.url ?? "",
username: entry.username ?? "",
password: entry.password,
notes: entry.notes ?? "",
folder: entry.folder ?? "",
tags: entry.tags ? entry.tags.join(",") : "",
}));
return Papa.unparse(rows, {
header: true,
columns,
});
}

View File

@@ -0,0 +1 @@
export { exportToCsv } from "./csv-export";

View File

@@ -0,0 +1,26 @@
// Types
// Core
export { detect } from "./core/detector/format-detector.js";
export { parse } from "./core/parse/parse.js";
export { validate } from "./core/validator/validate.js";
export type { PasswordFormatsCode } from "./errors/password-formats-error.js";
// Errors
export {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "./errors/password-formats-error.js";
// Export
export { exportToCsv } from "./export/csv-export.js";
// Parsers
export { parseApplePasswordsCsv } from "./parsers/apple-passwords/apple-passwords.js";
export { parseBitwardenJson } from "./parsers/bitwarden/bitwarden.js";
export { parseChromeCsv } from "./parsers/chrome/chrome.js";
export { parseKeePassXml } from "./parsers/keepass/keepass.js";
export type {
ParseOptions,
PasswordEntry,
PasswordFormat,
ValidationResult,
} from "./types/password-formats.types.js";

View File

@@ -0,0 +1,65 @@
# apple-passwords
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses Apple Passwords CSV exports into the normalized `PasswordEntry[]` format used throughout the `@suaveplan/password-formats` package. Apple's built-in password manager exports credentials as a CSV file with a fixed six-column header. This module handles that format specifically, mapping each column to the appropriate field in `PasswordEntry` and preserving the OTPAuth token as a custom field for TOTP-aware consumers.
## Features
- Uses `papaparse` with `header: true` for robust CSV parsing
- Maps `Title`, `URL`, `Username`, `Password`, `Notes` columns to their `PasswordEntry` counterparts
- Preserves `OTPAuth` value in `customFields.OTPAuth`
- Respects `ParseOptions.skipInvalid` to skip entries missing required fields
- Respects `ParseOptions.maxEntries` to cap the number of returned entries
- Throws `PasswordFormatsError` with code `PARSE_ERROR` for missing required fields when `skipInvalid` is false
## Usage: Basic Example
```ts
import { parseApplePasswordsCsv } from "@suaveplan/password-formats/parsers/apple";
const csv = `Title,URL,Username,Password,Notes,OTPAuth
My Bank,https://bank.com,user@example.com,s3cr3t,Main account,
GitHub,https://github.com,dev@example.com,gh_token,,`;
const entries = parseApplePasswordsCsv(csv);
console.log(entries[0].title); // "My Bank"
console.log(entries[1].url); // "https://github.com"
```
## Usage: Advanced Example
```ts
import { parseApplePasswordsCsv } from "@suaveplan/password-formats/parsers/apple";
const csv = `Title,URL,Username,Password,Notes,OTPAuth
My Account,https://example.com,me@example.com,secret,Backup,otpauth://totp/me@example.com?secret=BASE32`;
const entries = parseApplePasswordsCsv(csv, { skipInvalid: true, maxEntries: 100 });
console.log(entries[0].customFields?.OTPAuth); // "otpauth://totp/..."
```
## API Reference
### `parseApplePasswordsCsv`
```ts
function parseApplePasswordsCsv(input: string, options?: ParseOptions): PasswordEntry[]
```
**Parameters:**
- `input` — Raw Apple Passwords CSV string.
- `options.skipInvalid` — If `true`, rows missing `Title` or `Password` are silently skipped. Default: `false`.
- `options.maxEntries` — Maximum number of entries to return.
**Returns:** Array of `PasswordEntry` objects.
**Throws:** `PasswordFormatsError` (`PARSE_ERROR`) if a row is missing required fields and `skipInvalid` is `false`.
## Implementation Notes
Apple's CSV format always includes a header row with exactly six columns. The `OTPAuth` column contains an `otpauth://` URI when TOTP is configured for an entry, or an empty string otherwise. Empty `OTPAuth` values are omitted from `customFields` to keep entries clean. The parser does not validate the `otpauth://` URI format — it passes the raw string through verbatim so that TOTP consumers can parse it themselves.

View File

@@ -0,0 +1,157 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import { parseApplePasswordsCsv } from "./apple-passwords.js";
const APPLE_CSV = `Title,URL,Username,Password,Notes,OTPAuth
My Bank,https://bank.com,user@example.com,s3cr3t,Main account,
GitHub,https://github.com,dev@example.com,gh_token,,`;
const APPLE_CSV_WITH_OTP = `Title,URL,Username,Password,Notes,OTPAuth
My Account,https://example.com,me@example.com,secret,My notes,otpauth://totp/me@example.com?secret=BASE32`;
describe("parseApplePasswordsCsv", () => {
it("parses 2 entries from basic CSV", { timeout: 5000 }, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV);
expect(entries).toHaveLength(2);
});
it("maps Title field correctly", { timeout: 5000 }, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV);
expect(entries[0].title).toBe("My Bank");
expect(entries[1].title).toBe("GitHub");
});
it("maps URL field correctly", { timeout: 5000 }, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV);
expect(entries[0].url).toBe("https://bank.com");
expect(entries[1].url).toBe("https://github.com");
});
it("maps Username field correctly", { timeout: 5000 }, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV);
expect(entries[0].username).toBe("user@example.com");
expect(entries[1].username).toBe("dev@example.com");
});
it("maps Password field correctly", { timeout: 5000 }, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV);
expect(entries[0].password).toBe("s3cr3t");
expect(entries[1].password).toBe("gh_token");
});
it("maps Notes field correctly", { timeout: 5000 }, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV);
expect(entries[0].notes).toBe("Main account");
expect(entries[1].notes).toBeUndefined();
});
it("maps OTPAuth to customFields", { timeout: 5000 }, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV_WITH_OTP);
expect(entries[0].customFields).toBeDefined();
expect(entries[0].customFields?.OTPAuth).toBe(
"otpauth://totp/me@example.com?secret=BASE32"
);
});
it("does not set customFields when OTPAuth is empty", {
timeout: 5000,
}, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV);
expect(entries[0].customFields).toBeUndefined();
});
it("throws PARSE_ERROR when title is missing and skipInvalid is false", {
timeout: 5000,
}, () => {
const csv = `Title,URL,Username,Password,Notes,OTPAuth
,https://bank.com,user,pass,,`;
expect(() => parseApplePasswordsCsv(csv)).toThrow(PasswordFormatsError);
expect(() => parseApplePasswordsCsv(csv)).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("skips invalid entries when skipInvalid is true", {
timeout: 5000,
}, () => {
const csv = `Title,URL,Username,Password,Notes,OTPAuth
,https://bank.com,user,pass,,
ValidTitle,https://valid.com,user,pass,,`;
const entries = parseApplePasswordsCsv(csv, { skipInvalid: true });
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe("ValidTitle");
});
it("respects maxEntries option", { timeout: 5000 }, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV, { maxEntries: 1 });
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe("My Bank");
});
it("handles empty notes as undefined", { timeout: 5000 }, () => {
const entries = parseApplePasswordsCsv(APPLE_CSV);
expect(entries[1].notes).toBeUndefined();
});
it("throws PARSE_ERROR when password is empty and skipInvalid is false", {
timeout: 5000,
}, () => {
const csv = `Title,URL,Username,Password,Notes,OTPAuth
ValidTitle,https://example.com,user,,note,`;
expect(() => parseApplePasswordsCsv(csv)).toThrow(PasswordFormatsError);
expect(() => parseApplePasswordsCsv(csv)).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("handles entries without URL, Username, Notes columns", {
timeout: 5000,
}, () => {
const csv = "Title,Password,OTPAuth\nMinimal Entry,mypassword,";
const entries = parseApplePasswordsCsv(csv);
expect(entries[0].title).toBe("Minimal Entry");
expect(entries[0].url).toBeUndefined();
expect(entries[0].username).toBeUndefined();
expect(entries[0].notes).toBeUndefined();
});
it("skips row with undefined Title field (column absent) when skipInvalid", {
timeout: 5000,
}, () => {
// Row has fewer columns than header: Title column value is missing
const csv = `Title,URL,Username,Password,Notes,OTPAuth
ValidTitle,https://valid.com,user,pass,,
,,,,,`;
const entries = parseApplePasswordsCsv(csv, { skipInvalid: true });
expect(entries).toHaveLength(1);
});
it("handles CSV with no Title column (row.Title is undefined)", {
timeout: 5000,
}, () => {
// CSV without Title header — row.Title will be undefined, covering the ?. branch
const csv = `URL,Username,Password,Notes,OTPAuth
https://bank.com,user,pass,note,`;
const entries = parseApplePasswordsCsv(csv, { skipInvalid: true });
// title resolves to "" → entry is skipped
expect(entries).toHaveLength(0);
});
it("handles CSV with no Password column (row.Password is undefined)", {
timeout: 5000,
}, () => {
// CSV without Password header — row.Password will be undefined
const csv = `Title,URL,Username,Notes,OTPAuth
My Site,https://bank.com,user,note,`;
const entries = parseApplePasswordsCsv(csv, { skipInvalid: true });
// password resolves to "" → entry is skipped
expect(entries).toHaveLength(0);
});
});

View File

@@ -0,0 +1,76 @@
import Papa from "papaparse";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import type {
ParseOptions,
PasswordEntry,
} from "../../types/password-formats.types.js";
interface AppleRow {
Title?: string;
URL?: string;
Username?: string;
Password?: string;
Notes?: string;
OTPAuth?: string;
}
/**
* Parse an Apple Passwords CSV export into normalized PasswordEntry objects.
*
* Expected columns: Title, URL, Username, Password, Notes, OTPAuth
*
* @param input - CSV content string.
* @param options - Parse options.
* @returns Array of normalized PasswordEntry objects.
* @throws {PasswordFormatsError} With code `PARSE_ERROR` on malformed CSV (when skipInvalid is false).
*/
export function parseApplePasswordsCsv(
input: string,
options?: ParseOptions
): PasswordEntry[] {
const { skipInvalid = false, maxEntries } = options ?? {};
const result = Papa.parse<AppleRow>(input, {
header: true,
skipEmptyLines: true,
});
const entries: PasswordEntry[] = [];
let rowIndex = 0;
for (const row of result.data) {
if (maxEntries !== undefined && entries.length >= maxEntries) break;
const i = rowIndex++;
const title = row.Title?.trim() ?? "";
const password = row.Password ?? "";
if (!title || !password) {
if (skipInvalid) continue;
throw new PasswordFormatsError(
`Apple Passwords CSV row ${i + 1}: missing required Title or Password field`,
PASSWORD_FORMATS_CODES.PARSE_ERROR
);
}
const entry: PasswordEntry = {
title,
password,
};
if (row.URL?.trim()) entry.url = row.URL.trim();
if (row.Username?.trim()) entry.username = row.Username.trim();
if (row.Notes?.trim()) entry.notes = row.Notes.trim();
if (row.OTPAuth?.trim()) {
entry.customFields = { OTPAuth: row.OTPAuth.trim() };
}
entries.push(entry);
}
return entries;
}

View File

@@ -0,0 +1 @@
export { parseApplePasswordsCsv } from "./apple-passwords";

View File

@@ -0,0 +1,81 @@
# bitwarden
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses Bitwarden JSON exports into the normalized `PasswordEntry[]` format used throughout the `@suaveplan/password-formats` package. Bitwarden is a widely used open-source password manager that exports a JSON file containing an array of credential items with rich metadata including type discrimination, folder references, and revision timestamps. This module handles the full schema including login, secure note, card, and identity item types.
## Features
- Parses Bitwarden unencrypted JSON exports
- Throws `ENCRYPTED_NOT_SUPPORTED` immediately for encrypted vaults — no partial processing
- Maps Bitwarden item `type` numbers (1–4) to `PasswordEntry.type` strings (`login`, `note`, `card`, `identity`)
- Extracts the first URI from `login.uris` as the `url` field
- Preserves `creationDate` and `revisionDate` as ISO 8601 `createdAt`/`updatedAt` fields
- Maps `folderId` to `folder` for folder-aware consumers
- Respects `ParseOptions.skipInvalid` and `ParseOptions.maxEntries`
## Usage: Basic Example
```ts
import { parseBitwardenJson } from "@suaveplan/password-formats/parsers/bitwarden";
const json = JSON.stringify({
encrypted: false,
items: [{
id: "abc123",
name: "Gmail",
login: { username: "user@gmail.com", password: "pass123", uris: [{ uri: "https://gmail.com" }] },
notes: "work email",
favorite: false,
type: 1,
}],
});
const entries = parseBitwardenJson(json);
console.log(entries[0].title); // "Gmail"
console.log(entries[0].username); // "user@gmail.com"
console.log(entries[0].type); // "login"
```
## Usage: Advanced Example
```ts
import { parseBitwardenJson } from "@suaveplan/password-formats/parsers/bitwarden";
import { PasswordFormatsError, PASSWORD_FORMATS_CODES } from "@suaveplan/password-formats/errors";
try {
const entries = parseBitwardenJson(encryptedJson);
} catch (err) {
if (err instanceof PasswordFormatsError && err.code === PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED) {
console.error("Export without encryption first.");
}
}
```
## API Reference
### `parseBitwardenJson`
```ts
function parseBitwardenJson(input: string, options?: ParseOptions): PasswordEntry[]
```
**Parameters:**
- `input` — Bitwarden JSON export string.
- `options.skipInvalid` — Skip items missing a `name` field. Default: `false`.
- `options.maxEntries` — Cap on returned entries.
**Returns:** Array of `PasswordEntry` objects.
**Throws:**
- `PasswordFormatsError` (`ENCRYPTED_NOT_SUPPORTED`) — Export has `"encrypted": true`.
- `PasswordFormatsError` (`PARSE_ERROR`) — Input is not valid JSON or a required field is missing and `skipInvalid` is `false`.
## Implementation Notes
Bitwarden's `folderId` is a UUID reference to a folder object in the export's `folders` array. This module maps `folderId` directly to `entry.folder` as a raw string rather than resolving the folder name, since the full folders array is not always present. Consumers requiring resolved folder names should parse the `folders` array separately. The `type` field mapping follows Bitwarden's documented numeric scheme: 1=Login, 2=SecureNote, 3=Card, 4=Identity.

View File

@@ -0,0 +1,244 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import { parseBitwardenJson } from "./bitwarden.js";
const BITWARDEN_JSON = JSON.stringify({
encrypted: false,
items: [
{
id: "123",
name: "Gmail",
login: {
username: "test@gmail.com",
password: "pass123",
uris: [{ uri: "https://gmail.com" }],
},
notes: "work email",
favorite: false,
type: 1,
},
],
});
const BITWARDEN_ENCRYPTED_JSON = JSON.stringify({
encrypted: true,
items: [],
});
const BITWARDEN_MULTI_JSON = JSON.stringify({
encrypted: false,
items: [
{
id: "1",
name: "Login Entry",
login: {
username: "user1",
password: "pass1",
uris: [{ uri: "https://site1.com" }],
},
favorite: true,
type: 1,
creationDate: "2024-01-01T00:00:00Z",
revisionDate: "2024-06-01T00:00:00Z",
},
{
id: "2",
name: "Secure Note",
notes: "This is a note",
favorite: false,
type: 2,
},
{
id: "3",
name: "My Card",
type: 3,
},
{
id: "4",
name: "My Identity",
type: 4,
},
],
});
describe("parseBitwardenJson", () => {
it("parses basic entry correctly", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_JSON);
expect(entries).toHaveLength(1);
});
it("maps name to title", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_JSON);
expect(entries[0].title).toBe("Gmail");
});
it("maps login.username to username", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_JSON);
expect(entries[0].username).toBe("test@gmail.com");
});
it("maps login.password to password", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_JSON);
expect(entries[0].password).toBe("pass123");
});
it("maps first URI to url", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_JSON);
expect(entries[0].url).toBe("https://gmail.com");
});
it("maps notes field", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_JSON);
expect(entries[0].notes).toBe("work email");
});
it("maps id field", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_JSON);
expect(entries[0].id).toBe("123");
});
it("maps type 1 to login", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_JSON);
expect(entries[0].type).toBe("login");
});
it("throws ENCRYPTED_NOT_SUPPORTED for encrypted vaults", {
timeout: 5000,
}, () => {
expect(() => parseBitwardenJson(BITWARDEN_ENCRYPTED_JSON)).toThrow(
PasswordFormatsError
);
expect(() => parseBitwardenJson(BITWARDEN_ENCRYPTED_JSON)).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED,
})
);
});
it("maps type numbers correctly", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_MULTI_JSON);
expect(entries[0].type).toBe("login");
expect(entries[1].type).toBe("note");
expect(entries[2].type).toBe("card");
expect(entries[3].type).toBe("identity");
});
it("maps creationDate and revisionDate to ISO dates", {
timeout: 5000,
}, () => {
const entries = parseBitwardenJson(BITWARDEN_MULTI_JSON);
expect(entries[0].createdAt).toBe("2024-01-01T00:00:00Z");
expect(entries[0].updatedAt).toBe("2024-06-01T00:00:00Z");
});
it("maps favorite field", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_MULTI_JSON);
expect(entries[0].favorite).toBe(true);
expect(entries[1].favorite).toBe(false);
});
it("throws PARSE_ERROR for invalid JSON", { timeout: 5000 }, () => {
expect(() => parseBitwardenJson("not valid json")).toThrow(
PasswordFormatsError
);
expect(() => parseBitwardenJson("not valid json")).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("throws PARSE_ERROR for missing name when skipInvalid is false", {
timeout: 5000,
}, () => {
const json = JSON.stringify({
encrypted: false,
items: [{ id: "1", type: 1, login: { password: "pass" } }],
});
expect(() => parseBitwardenJson(json)).toThrow(PasswordFormatsError);
expect(() => parseBitwardenJson(json)).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("skips invalid entries when skipInvalid is true", {
timeout: 5000,
}, () => {
const json = JSON.stringify({
encrypted: false,
items: [
{ id: "1", type: 1 },
{
id: "2",
name: "Valid",
type: 1,
login: { password: "pass" },
},
],
});
const entries = parseBitwardenJson(json, { skipInvalid: true });
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe("Valid");
});
it("respects maxEntries option", { timeout: 5000 }, () => {
const entries = parseBitwardenJson(BITWARDEN_MULTI_JSON, {
maxEntries: 2,
});
expect(entries).toHaveLength(2);
});
it("handles empty items array", { timeout: 5000 }, () => {
const json = JSON.stringify({ encrypted: false, items: [] });
const entries = parseBitwardenJson(json);
expect(entries).toHaveLength(0);
});
it("handles item without id", { timeout: 5000 }, () => {
const json = JSON.stringify({
encrypted: false,
items: [{ name: "NoId", type: 1, login: { password: "pass" } }],
});
const entries = parseBitwardenJson(json);
expect(entries[0].id).toBeUndefined();
});
it("handles item with folderId", { timeout: 5000 }, () => {
const json = JSON.stringify({
encrypted: false,
items: [
{
id: "1",
name: "Foldered",
folderId: "folder-uuid-123",
type: 1,
login: { password: "pass" },
},
],
});
const entries = parseBitwardenJson(json);
expect(entries[0].folder).toBe("folder-uuid-123");
});
it("handles item without type (type is undefined)", {
timeout: 5000,
}, () => {
const json = JSON.stringify({
encrypted: false,
items: [{ name: "NoType", login: { password: "pass" } }],
});
const entries = parseBitwardenJson(json);
expect(entries[0].type).toBeUndefined();
});
it("handles missing items key gracefully", { timeout: 5000 }, () => {
const json = JSON.stringify({ encrypted: false });
const entries = parseBitwardenJson(json);
expect(entries).toHaveLength(0);
});
});

View File

@@ -0,0 +1,126 @@
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import type {
ParseOptions,
PasswordEntry,
} from "../../types/password-formats.types.js";
interface BitwardenUri {
uri?: string;
match?: number | null;
}
interface BitwardenLogin {
username?: string | null;
password?: string | null;
uris?: BitwardenUri[] | null;
}
interface BitwardenItem {
id?: string;
name?: string;
login?: BitwardenLogin | null;
notes?: string | null;
folderId?: string | null;
favorite?: boolean;
type?: number;
creationDate?: string;
revisionDate?: string;
}
interface BitwardenExport {
encrypted?: boolean;
items?: BitwardenItem[];
}
const TYPE_MAP: Record<number, PasswordEntry["type"]> = {
1: "login",
2: "note",
3: "card",
4: "identity",
};
/**
* Parse a Bitwarden JSON export into normalized PasswordEntry objects.
*
* @param input - Bitwarden JSON export string.
* @param options - Parse options.
* @returns Array of normalized PasswordEntry objects.
* @throws {PasswordFormatsError} With code `ENCRYPTED_NOT_SUPPORTED` if the export is encrypted.
* @throws {PasswordFormatsError} With code `PARSE_ERROR` on malformed JSON (when skipInvalid is false).
*/
export function parseBitwardenJson(
input: string,
options?: ParseOptions
): PasswordEntry[] {
const { skipInvalid = false, maxEntries } = options ?? {};
let data: BitwardenExport;
try {
data = JSON.parse(input) as BitwardenExport;
} catch {
throw new PasswordFormatsError(
"Bitwarden export is not valid JSON",
PASSWORD_FORMATS_CODES.PARSE_ERROR
);
}
if (data.encrypted === true) {
throw new PasswordFormatsError(
"Bitwarden encrypted vaults are not supported. Please export an unencrypted vault.",
PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED
);
}
const items = data.items ?? [];
const entries: PasswordEntry[] = [];
let itemIndex = 0;
for (const item of items) {
if (maxEntries !== undefined && entries.length >= maxEntries) break;
const i = itemIndex++;
const title = item.name?.trim() ?? "";
// For login type, password is required
const password = item.login?.password ?? "";
if (!title) {
if (skipInvalid) continue;
throw new PasswordFormatsError(
`Bitwarden item at index ${i}: missing required name field`,
PASSWORD_FORMATS_CODES.PARSE_ERROR
);
}
const entry: PasswordEntry = {
title,
password,
};
if (item.id) entry.id = item.id;
if (item.login?.username) entry.username = item.login.username;
const uris = item.login?.uris;
if (uris && uris.length > 0 && uris[0]?.uri) {
entry.url = uris[0].uri;
}
if (item.notes?.trim()) entry.notes = item.notes.trim();
if (item.folderId?.trim()) entry.folder = item.folderId.trim();
if (item.favorite !== undefined) entry.favorite = item.favorite;
const itemType =
item.type !== undefined ? TYPE_MAP[item.type] : undefined;
if (itemType) entry.type = itemType;
if (item.creationDate) entry.createdAt = item.creationDate;
if (item.revisionDate) entry.updatedAt = item.revisionDate;
entries.push(entry);
}
return entries;
}

View File

@@ -0,0 +1 @@
export { parseBitwardenJson } from "./bitwarden";

View File

@@ -0,0 +1,66 @@
# chrome
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses Google Chrome CSV password exports into the normalized `PasswordEntry[]` format used throughout the `@suaveplan/password-formats` package. Chrome's built-in password manager (and Chromium-based browsers including Edge, Brave, and Opera) exports a minimal CSV with four columns. This module maps that simple schema to `PasswordEntry` objects, providing a clean integration path for users migrating away from browser-stored passwords.
## Features
- Uses `papaparse` with `header: true` for robust CSV parsing including quoted values
- Maps Chrome columns: `name` → `title`, `url` → `url`, `username` → `username`, `password` → `password`
- Respects `ParseOptions.skipInvalid` to skip rows missing required fields
- Respects `ParseOptions.maxEntries` to cap the number of returned entries
- Throws `PasswordFormatsError` (`PARSE_ERROR`) on missing required fields when `skipInvalid` is false
## Usage: Basic Example
```ts
import { parseChromeCsv } from "@suaveplan/password-formats/parsers/chrome";
const csv = `name,url,username,password
Gmail,https://gmail.com,test@gmail.com,pass123`;
const entries = parseChromeCsv(csv);
console.log(entries[0].title); // "Gmail"
console.log(entries[0].url); // "https://gmail.com"
console.log(entries[0].username); // "test@gmail.com"
```
## Usage: Advanced Example
```ts
import { parseChromeCsv } from "@suaveplan/password-formats/parsers/chrome";
const csv = `name,url,username,password
Gmail,https://gmail.com,me@gmail.com,secret
,https://missing-name.com,user,pass`;
// Skip the second row (missing name)
const entries = parseChromeCsv(csv, { skipInvalid: true, maxEntries: 50 });
console.log(entries.length); // 1 — the invalid row was skipped
```
## API Reference
### `parseChromeCsv`
```ts
function parseChromeCsv(input: string, options?: ParseOptions): PasswordEntry[]
```
**Parameters:**
- `input` — Chrome CSV export string.
- `options.skipInvalid` — Skip rows missing `name` or `password`. Default: `false`.
- `options.maxEntries` — Maximum number of entries to return.
**Returns:** Array of `PasswordEntry` objects.
**Throws:** `PasswordFormatsError` (`PARSE_ERROR`) when a row is missing required fields and `skipInvalid` is `false`.
## Implementation Notes
Chrome's CSV header uses lowercase column names (`name`, not `Name`). The parser passes the raw CSV through `papaparse` which handles header normalisation. Empty `url` and `username` values are omitted from the resulting entry object rather than set to empty strings, keeping the output clean. Chrome does not export notes, folders, or custom fields — those fields are simply absent from exported entries.

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import { parseChromeCsv } from "./chrome.js";
const CHROME_CSV =
"name,url,username,password\nGmail,https://gmail.com,test@gmail.com,pass123";
const CHROME_CSV_MULTI = `name,url,username,password
Gmail,https://gmail.com,test@gmail.com,pass123
GitHub,https://github.com,dev@example.com,ghtoken
NoUrl,,user@example.com,mypass`;
describe("parseChromeCsv", () => {
it("parses single entry correctly", { timeout: 5000 }, () => {
const entries = parseChromeCsv(CHROME_CSV);
expect(entries).toHaveLength(1);
});
it("maps name to title", { timeout: 5000 }, () => {
const entries = parseChromeCsv(CHROME_CSV);
expect(entries[0].title).toBe("Gmail");
});
it("maps url field", { timeout: 5000 }, () => {
const entries = parseChromeCsv(CHROME_CSV);
expect(entries[0].url).toBe("https://gmail.com");
});
it("maps username field", { timeout: 5000 }, () => {
const entries = parseChromeCsv(CHROME_CSV);
expect(entries[0].username).toBe("test@gmail.com");
});
it("maps password field", { timeout: 5000 }, () => {
const entries = parseChromeCsv(CHROME_CSV);
expect(entries[0].password).toBe("pass123");
});
it("parses multiple entries", { timeout: 5000 }, () => {
const entries = parseChromeCsv(CHROME_CSV_MULTI);
expect(entries).toHaveLength(3);
expect(entries[1].title).toBe("GitHub");
});
it("omits url when empty", { timeout: 5000 }, () => {
const entries = parseChromeCsv(CHROME_CSV_MULTI);
expect(entries[2].url).toBeUndefined();
});
it("throws PARSE_ERROR when name is missing and skipInvalid is false", {
timeout: 5000,
}, () => {
const csv =
"name,url,username,password\n,https://example.com,user,pass";
expect(() => parseChromeCsv(csv)).toThrow(PasswordFormatsError);
expect(() => parseChromeCsv(csv)).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("skips invalid entries when skipInvalid is true", {
timeout: 5000,
}, () => {
const csv =
"name,url,username,password\n,https://example.com,user,pass\nValid,https://valid.com,u,p";
const entries = parseChromeCsv(csv, { skipInvalid: true });
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe("Valid");
});
it("respects maxEntries option", { timeout: 5000 }, () => {
const entries = parseChromeCsv(CHROME_CSV_MULTI, { maxEntries: 1 });
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe("Gmail");
});
it("throws PARSE_ERROR when password is empty and skipInvalid is false", {
timeout: 5000,
}, () => {
const csv = "name,url,username,password\nGmail,https://gmail.com,user,";
expect(() => parseChromeCsv(csv)).toThrow(PasswordFormatsError);
expect(() => parseChromeCsv(csv)).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("omits username when empty", { timeout: 5000 }, () => {
const csv =
"name,url,username,password\nGmail,https://gmail.com,,pass123";
const entries = parseChromeCsv(csv);
expect(entries[0].username).toBeUndefined();
});
it("skips row with undefined name field when skipInvalid", {
timeout: 5000,
}, () => {
// Row with missing columns to produce undefined name
const csv = "name,url,username,password\n,,,\nValid,https://v.com,u,p";
const entries = parseChromeCsv(csv, { skipInvalid: true });
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe("Valid");
});
it("handles CSV with no name column (row.name is undefined)", {
timeout: 5000,
}, () => {
// CSV without name header — row.name is undefined, covering the ?. branch
const csv = "url,username,password\nhttps://gmail.com,user,pass123";
const entries = parseChromeCsv(csv, { skipInvalid: true });
// title resolves to "" → entry is skipped
expect(entries).toHaveLength(0);
});
it("handles CSV with no password column (row.password is undefined)", {
timeout: 5000,
}, () => {
// CSV without password header — row.password is undefined
const csv = "name,url,username\nGmail,https://gmail.com,user";
const entries = parseChromeCsv(csv, { skipInvalid: true });
// password resolves to "" → entry is skipped
expect(entries).toHaveLength(0);
});
});

View File

@@ -0,0 +1,69 @@
import Papa from "papaparse";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import type {
ParseOptions,
PasswordEntry,
} from "../../types/password-formats.types.js";
interface ChromeRow {
name?: string;
url?: string;
username?: string;
password?: string;
}
/**
* Parse a Google Chrome CSV password export into normalized PasswordEntry objects.
*
* Expected columns: name, url, username, password
*
* @param input - CSV content string.
* @param options - Parse options.
* @returns Array of normalized PasswordEntry objects.
* @throws {PasswordFormatsError} With code `PARSE_ERROR` when a required field is missing (unless skipInvalid is true).
*/
export function parseChromeCsv(
input: string,
options?: ParseOptions
): PasswordEntry[] {
const { skipInvalid = false, maxEntries } = options ?? {};
const result = Papa.parse<ChromeRow>(input, {
header: true,
skipEmptyLines: true,
});
const entries: PasswordEntry[] = [];
let rowIndex = 0;
for (const row of result.data) {
if (maxEntries !== undefined && entries.length >= maxEntries) break;
const i = rowIndex++;
const title = row.name?.trim() ?? "";
const password = row.password ?? "";
if (!title || !password) {
if (skipInvalid) continue;
throw new PasswordFormatsError(
`Chrome CSV row ${i + 1}: missing required name or password field`,
PASSWORD_FORMATS_CODES.PARSE_ERROR
);
}
const entry: PasswordEntry = {
title,
password,
};
if (row.url?.trim()) entry.url = row.url.trim();
if (row.username?.trim()) entry.username = row.username.trim();
entries.push(entry);
}
return entries;
}

View File

@@ -0,0 +1 @@
export { parseChromeCsv } from "./chrome";

View File

@@ -0,0 +1,4 @@
export { parseApplePasswordsCsv } from "./apple-passwords";
export { parseBitwardenJson } from "./bitwarden";
export { parseChromeCsv } from "./chrome";
export { parseKeePassXml } from "./keepass";

View File

@@ -0,0 +1 @@
export { parseKeePassXml } from "./keepass";

View File

@@ -0,0 +1,90 @@
# keepass
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses KeePass XML exports into the normalized `PasswordEntry[]` format used throughout the `@suaveplan/password-formats` package. KeePass is a widely used open-source password manager that can export credentials as an unencrypted XML file. This module handles the hierarchical group structure of KeePass XML, recursively walking all nested `Group` elements and building a dot-separated folder path from the group name chain, enabling consumers to reconstruct the organizational hierarchy.
## Features
- Uses `fast-xml-parser` for robust XML parsing without DOM dependencies
- Recursively walks nested `Group` elements to collect all `Entry` elements
- Builds `folder` path from parent group name chain (e.g. `"Personal/Work/Finance"`)
- Maps KeePass `String` key-value pairs: `Title`, `Password`, `URL`, `UserName`, `Notes`
- Preserves non-standard `String` fields in `customFields`
- Respects `ParseOptions.skipInvalid` and `ParseOptions.maxEntries`
- Throws `PasswordFormatsError` (`PARSE_ERROR`) on malformed XML or missing required fields
## Usage: Basic Example
```ts
import { parseKeePassXml } from "@suaveplan/password-formats/parsers/keepass";
const xml = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Key>Title</Key><Value>My Site</Value></String>
<String><Key>Password</Key><Value>secret</Value></String>
<String><Key>URL</Key><Value>https://example.com</Value></String>
<String><Key>UserName</Key><Value>user</Value></String>
<String><Key>Notes</Key><Value>notes here</Value></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
const entries = parseKeePassXml(xml);
console.log(entries[0].title); // "My Site"
console.log(entries[0].username); // "user"
```
## Usage: Advanced Example
```ts
import { parseKeePassXml } from "@suaveplan/password-formats/parsers/keepass";
// Nested groups produce folder paths
const xml = `<KeePassFile><Root><Group>
<Name>Root</Name>
<Group>
<Name>Personal</Name>
<Group>
<Name>Banking</Name>
<Entry>
<String><Key>Title</Key><Value>My Bank</Value></String>
<String><Key>Password</Key><Value>bankpass</Value></String>
</Entry>
</Group>
</Group>
</Group></Root></KeePassFile>`;
const entries = parseKeePassXml(xml);
console.log(entries[0].folder); // "Personal/Banking"
```
## API Reference
### `parseKeePassXml`
```ts
function parseKeePassXml(input: string, options?: ParseOptions): PasswordEntry[]
```
**Parameters:**
- `input` — KeePass XML export string (unencrypted `.xml`, not `.kdbx` binary).
- `options.skipInvalid` — Skip entries missing `Title` or `Password`. Default: `false`.
- `options.maxEntries` — Maximum number of entries to return.
**Returns:** Array of `PasswordEntry` objects.
**Throws:** `PasswordFormatsError` (`PARSE_ERROR`) on malformed XML, missing root structure, or missing required fields when `skipInvalid` is `false`.
## Implementation Notes
KeePass XML uses a `<String>` element with `<Key>` and `<Value>` child elements to represent each field. The root group name is included in the folder path only for nested groups — top-level entries in the root group have an empty `folder`. The `fast-xml-parser` `isArray` callback is configured to always return `true` for non-attribute nodes, ensuring `Entry` and `Group` are always arrays even when only one element is present. This eliminates the need for dual single/array handling throughout the recursive walk.

View File

@@ -0,0 +1,348 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import { parseKeePassXml } from "./keepass.js";
const KEEPASS_XML = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Key>Title</Key><Value>Test Entry</Value></String>
<String><Key>Password</Key><Value>testpass</Value></String>
<String><Key>URL</Key><Value>https://example.com</Value></String>
<String><Key>UserName</Key><Value>testuser</Value></String>
<String><Key>Notes</Key><Value>test notes</Value></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
const KEEPASS_NESTED_XML = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Root</Name>
<Group>
<Name>Personal</Name>
<Entry>
<String><Key>Title</Key><Value>Personal Entry</Value></String>
<String><Key>Password</Key><Value>personalpass</Value></String>
</Entry>
<Group>
<Name>Banking</Name>
<Entry>
<String><Key>Title</Key><Value>Bank Account</Value></String>
<String><Key>Password</Key><Value>bankpass</Value></String>
<String><Key>URL</Key><Value>https://bank.com</Value></String>
</Entry>
</Group>
</Group>
</Group>
</Root>
</KeePassFile>`;
const KEEPASS_CUSTOM_FIELDS_XML = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Key>Title</Key><Value>Custom Entry</Value></String>
<String><Key>Password</Key><Value>custompass</Value></String>
<String><Key>MyCustomField</Key><Value>custom value</Value></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
// Force coverage of unwrap/unwrapArray non-array paths and getStringFields edge cases
// by constructing XML where fast-xml-parser might produce non-array values.
// These tests also cover paths via document structure.
describe("parseKeePassXml", () => {
it("parses a single entry", { timeout: 5000 }, () => {
const entries = parseKeePassXml(KEEPASS_XML);
expect(entries).toHaveLength(1);
});
it("maps Title to title", { timeout: 5000 }, () => {
const entries = parseKeePassXml(KEEPASS_XML);
expect(entries[0].title).toBe("Test Entry");
});
it("maps Password field", { timeout: 5000 }, () => {
const entries = parseKeePassXml(KEEPASS_XML);
expect(entries[0].password).toBe("testpass");
});
it("maps URL field", { timeout: 5000 }, () => {
const entries = parseKeePassXml(KEEPASS_XML);
expect(entries[0].url).toBe("https://example.com");
});
it("maps UserName to username", { timeout: 5000 }, () => {
const entries = parseKeePassXml(KEEPASS_XML);
expect(entries[0].username).toBe("testuser");
});
it("maps Notes field", { timeout: 5000 }, () => {
const entries = parseKeePassXml(KEEPASS_XML);
expect(entries[0].notes).toBe("test notes");
});
it("handles nested groups with folder paths", { timeout: 5000 }, () => {
const entries = parseKeePassXml(KEEPASS_NESTED_XML);
expect(entries).toHaveLength(2);
const personal = entries.find((e) => e.title === "Personal Entry");
expect(personal?.folder).toBe("Personal");
const bank = entries.find((e) => e.title === "Bank Account");
expect(bank?.folder).toBe("Personal/Banking");
});
it("preserves custom fields", { timeout: 5000 }, () => {
const entries = parseKeePassXml(KEEPASS_CUSTOM_FIELDS_XML);
expect(entries[0].customFields?.MyCustomField).toBe("custom value");
});
it("throws PARSE_ERROR for malformed XML that fast-xml-parser rejects", {
timeout: 5000,
}, () => {
expect(() => parseKeePassXml("< invalid xml")).toThrow(
PasswordFormatsError
);
expect(() => parseKeePassXml("< invalid xml")).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("throws PARSE_ERROR for XML that fast-xml-parser accepts but lacks KeePassFile", {
timeout: 5000,
}, () => {
// fast-xml-parser parses this but produces no KeePassFile key
expect(() => parseKeePassXml("<not valid xml>>>")).toThrow(
PasswordFormatsError
);
});
it("throws PARSE_ERROR for empty Group element when skipInvalid is false", {
timeout: 5000,
}, () => {
// fast-xml-parser returns "" for an empty Group element
expect(() =>
parseKeePassXml(
"<KeePassFile><Root><Group></Group></Root></KeePassFile>"
)
).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("returns empty array for empty Group element when skipInvalid is true", {
timeout: 5000,
}, () => {
const entries = parseKeePassXml(
"<KeePassFile><Root><Group></Group></Root></KeePassFile>",
{ skipInvalid: true }
);
expect(entries).toHaveLength(0);
});
it("throws PARSE_ERROR for missing root group when skipInvalid is false", {
timeout: 5000,
}, () => {
expect(() =>
parseKeePassXml("<KeePassFile><Root></Root></KeePassFile>")
).toThrow(PasswordFormatsError);
expect(() =>
parseKeePassXml("<KeePassFile><Root></Root></KeePassFile>")
).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("returns empty array for missing root group when skipInvalid is true", {
timeout: 5000,
}, () => {
const entries = parseKeePassXml(
"<KeePassFile><Root></Root></KeePassFile>",
{ skipInvalid: true }
);
expect(entries).toHaveLength(0);
});
it("respects maxEntries option", { timeout: 5000 }, () => {
const entries = parseKeePassXml(KEEPASS_NESTED_XML, { maxEntries: 1 });
expect(entries).toHaveLength(1);
});
it("skips invalid entries when skipInvalid is true", {
timeout: 5000,
}, () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Key>Title</Key><Value></Value></String>
<String><Key>Password</Key><Value>pass</Value></String>
</Entry>
<Entry>
<String><Key>Title</Key><Value>Valid Entry</Value></String>
<String><Key>Password</Key><Value>validpass</Value></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
const entries = parseKeePassXml(xml, { skipInvalid: true });
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe("Valid Entry");
});
it("handles group with no Name element", { timeout: 5000 }, () => {
// Group without <Name> element — first(subGroup.Name) is undefined, fallback to ""
const xml = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Root</Name>
<Group>
<Entry>
<String><Key>Title</Key><Value>Unnamed Group Entry</Value></String>
<String><Key>Password</Key><Value>pass</Value></String>
</Entry>
</Group>
</Group>
</Root>
</KeePassFile>`;
const entries = parseKeePassXml(xml);
expect(entries).toHaveLength(1);
// Empty group name → empty folder path → folder is undefined (not set)
expect(entries[0].folder).toBeUndefined();
});
it("handles entry with no Title String field (undefined Title)", {
timeout: 5000,
}, () => {
// Entry has only Password — fields["Title"] is undefined, covers ?. branch
const xml = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Key>Password</Key><Value>pass</Value></String>
</Entry>
<Entry>
<String><Key>Title</Key><Value>Valid</Value></String>
<String><Key>Password</Key><Value>validpass</Value></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
const entries = parseKeePassXml(xml, { skipInvalid: true });
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe("Valid");
});
it("handles String field with no Key (key is undefined, skipped)", {
timeout: 5000,
}, () => {
// String item has no Key — first(item.Key) is undefined, field not added
const xml = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Value>orphan value</Value></String>
<String><Key>Title</Key><Value>Entry With Orphan</Value></String>
<String><Key>Password</Key><Value>pass</Value></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
const entries = parseKeePassXml(xml);
expect(entries[0].title).toBe("Entry With Orphan");
});
it("handles String field with undefined Value (empty value fallback)", {
timeout: 5000,
}, () => {
// <Value/> self-closing produces undefined in array — first returns undefined
const xml = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Key>Title</Key><Value>Has Empty Val</Value></String>
<String><Key>Password</Key><Value>pass</Value></String>
<String><Key>CustomField</Key></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
// CustomField has no Value — rawVal will be undefined
const entries = parseKeePassXml(xml);
expect(entries[0].title).toBe("Has Empty Val");
});
it("throws PARSE_ERROR for entry missing Password when skipInvalid is false", {
timeout: 5000,
}, () => {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Key>Title</Key><Value>No Pass Entry</Value></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
expect(() => parseKeePassXml(xml)).toThrow(PasswordFormatsError);
expect(() => parseKeePassXml(xml)).toThrow(
expect.objectContaining({
code: PASSWORD_FORMATS_CODES.PARSE_ERROR,
})
);
});
it("skips entry with no Password String field when skipInvalid is true", {
timeout: 5000,
}, () => {
// Entry has Title but no Password key — fields["Password"] is undefined
const xml = `<?xml version="1.0" encoding="utf-8"?>
<KeePassFile>
<Root>
<Group>
<Name>Database</Name>
<Entry>
<String><Key>Title</Key><Value>No Pass</Value></String>
</Entry>
<Entry>
<String><Key>Title</Key><Value>Has Pass</Value></String>
<String><Key>Password</Key><Value>secret</Value></String>
</Entry>
</Group>
</Root>
</KeePassFile>`;
const entries = parseKeePassXml(xml, { skipInvalid: true });
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe("Has Pass");
});
});

View File

@@ -0,0 +1,197 @@
import { XMLParser } from "fast-xml-parser";
import {
PASSWORD_FORMATS_CODES,
PasswordFormatsError,
} from "../../errors/password-formats-error.js";
import type {
ParseOptions,
PasswordEntry,
} from "../../types/password-formats.types.js";
/**
* With isArray: () => true, fast-xml-parser always wraps non-attribute values in arrays.
* These helpers safely extract values from those arrays.
*/
/** Get first element of an array, or undefined if empty/not provided. */
function first<T>(v: T[] | undefined): T | undefined {
if (!v || v.length === 0) return undefined;
return v[0];
}
/** Get array value, defaulting to empty array. */
function toArray<T>(v: T[] | undefined): T[] {
return v ?? [];
}
interface KeePassStringField {
/** With isArray:true, Key is always string[]. */
Key?: string[];
/** With isArray:true, Value is always (string|number|boolean)[]. */
Value?: (string | number | boolean)[];
}
interface KeePassEntry {
String?: KeePassStringField[];
}
interface KeePassGroup {
Name?: string[];
Entry?: KeePassEntry[];
Group?: KeePassGroup[];
}
interface KeePassRoot {
Group?: KeePassGroup[];
}
interface KeePassFile {
Root?: KeePassRoot[];
}
interface ParsedXml {
KeePassFile?: KeePassFile[];
}
function getStringFields(entry: KeePassEntry): Record<string, string> {
const fields: Record<string, string> = {};
for (const item of toArray(entry.String)) {
const key = first(item.Key);
const rawVal = first(item.Value);
const value =
rawVal === undefined || rawVal === null ? "" : String(rawVal);
if (key) {
fields[key] = value;
}
}
return fields;
}
function processEntries(
group: KeePassGroup,
folderPath: string,
entries: PasswordEntry[],
options: { skipInvalid: boolean; maxEntries?: number },
totalCount: { value: number }
): void {
for (const rawEntry of toArray(group.Entry)) {
if (
options.maxEntries !== undefined &&
totalCount.value >= options.maxEntries
)
return;
const fields = getStringFields(rawEntry);
const title = fields["Title"]?.trim() ?? "";
const password = fields["Password"] ?? "";
if (!title || !password) {
if (options.skipInvalid) continue;
throw new PasswordFormatsError(
"KeePass entry missing required Title or Password field",
PASSWORD_FORMATS_CODES.PARSE_ERROR
);
}
const entry: PasswordEntry = {
title,
password,
};
if (fields["URL"]?.trim()) entry.url = fields["URL"].trim();
if (fields["UserName"]?.trim())
entry.username = fields["UserName"].trim();
if (fields["Notes"]?.trim()) entry.notes = fields["Notes"].trim();
if (folderPath) entry.folder = folderPath;
// Any other string fields become customFields
const standardKeys = new Set([
"Title",
"Password",
"URL",
"UserName",
"Notes",
]);
const customFields: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) {
if (!standardKeys.has(key) && value) {
customFields[key] = value;
}
}
if (Object.keys(customFields).length > 0) {
entry.customFields = customFields;
}
entries.push(entry);
totalCount.value++;
}
// Recurse into nested groups
for (const subGroup of toArray(group.Group)) {
const subName = String(first(subGroup.Name) ?? "").trim();
const subPath = folderPath ? `${folderPath}/${subName}` : subName;
processEntries(subGroup, subPath, entries, options, totalCount);
}
}
/**
* Parse a KeePass XML export into normalized PasswordEntry objects.
*
* Supports unencrypted KeePass XML exports (not KDBX binary format).
* Recursively walks all nested Group elements.
*
* @param input - KeePass XML string.
* @param options - Parse options.
* @returns Array of normalized PasswordEntry objects.
* @throws {PasswordFormatsError} With code `PARSE_ERROR` on malformed XML or missing fields.
*/
export function parseKeePassXml(
input: string,
options?: ParseOptions
): PasswordEntry[] {
const { skipInvalid = false, maxEntries } = options ?? {};
const parser = new XMLParser({
ignoreAttributes: false,
isArray: (_name, _jpath, _isLeafNode, isAttribute) => !isAttribute,
parseTagValue: true,
parseAttributeValue: false,
});
let parsed: ParsedXml;
try {
parsed = parser.parse(input) as ParsedXml;
} catch {
throw new PasswordFormatsError(
"KeePass XML is malformed",
PASSWORD_FORMATS_CODES.PARSE_ERROR
);
}
const kpFile = first(parsed?.KeePassFile);
const rootWrapper = first(kpFile?.Root);
const group = first(rootWrapper?.Group);
if (!group || typeof group !== "object") {
if (skipInvalid) return [];
throw new PasswordFormatsError(
"KeePass XML does not contain a valid Root/Group structure",
PASSWORD_FORMATS_CODES.PARSE_ERROR
);
}
const entries: PasswordEntry[] = [];
const totalCount = { value: 0 };
const processOptions: { skipInvalid: boolean; maxEntries?: number } = {
skipInvalid,
};
if (maxEntries !== undefined) {
processOptions.maxEntries = maxEntries;
}
processEntries(group, "", entries, processOptions, totalCount);
return entries;
}

View File

@@ -0,0 +1,6 @@
export type {
ParseOptions,
PasswordEntry,
PasswordFormat,
ValidationResult,
} from "./password-formats.types";

View File

@@ -0,0 +1,83 @@
# password-formats.types
## Purpose
Defines the shared TypeScript types used across the `@suaveplan/password-formats` package. These types form the canonical data contract between the format-specific parsers (Apple Passwords, Bitwarden, Chrome, KeePass), the auto-detector, the `validate` helper, and any downstream consumers that process or re-export credential data.
## Features
- `PasswordFormat` discriminated union for all supported export formats plus `"unknown"` for unrecognised inputs.
- `PasswordEntry` normalised interface covering all common fields (title, username, password, URL, notes, folder, tags, custom fields, timestamps, entry type).
- `ParseOptions` for controlling parser behaviour (skip invalid entries, cap result size).
- `ValidationResult` summary type returned by the `validate` helper, listing per-entry errors with their index positions.
## Usage: Basic Example
```ts
import type { PasswordEntry, PasswordFormat } from '@suaveplan/password-formats';
const entry: PasswordEntry = {
title: 'GitHub',
username: 'alice@example.com',
password: 's3cr3t!',
};
```
## Usage: Advanced Example
```ts
import type { ParseOptions, ValidationResult } from '@suaveplan/password-formats';
const opts: ParseOptions = { skipInvalid: true, maxEntries: 500 };
function reportErrors(result: ValidationResult): void {
if (!result.valid) {
for (const err of result.errors) {
console.error(`Entry ${err.index}: ${err.message}`);
}
}
}
```
## API Reference
### `PasswordFormat`
String literal union: `"apple" | "bitwarden" | "chrome" | "keepass" | "unknown"`. Returned by the auto-detector and accepted by the format-specific parser selection logic.
### `PasswordEntry`
| Field | Type | Required | Description |
|---|---|---|---|
| `title` | `string` | yes | Display name or site title |
| `password` | `string` | yes | Plaintext credential |
| `username` | `string?` | no | Login username or email |
| `url` | `string?` | no | Associated URL |
| `notes` | `string?` | no | Free-text notes |
| `folder` | `string?` | no | Organisational group |
| `favorite` | `boolean?` | no | Starred / pinned flag |
| `createdAt` | `string?` | no | ISO 8601 creation timestamp |
| `updatedAt` | `string?` | no | ISO 8601 last-modified timestamp |
| `tags` | `string[]?` | no | Arbitrary labels |
| `customFields` | `Record<string,string>?` | no | Manager-specific key/value pairs |
| `type` | `"login"\|"card"\|"note"\|"identity"?` | no | Entry category |
### `ParseOptions`
| Field | Type | Default | Description |
|---|---|---|---|
| `skipInvalid` | `boolean` | `false` | Skip malformed entries instead of throwing |
| `maxEntries` | `number` | unlimited | Stop parsing after this many entries |
### `ValidationResult`
| Field | Type | Description |
|---|---|---|
| `valid` | `boolean` | `true` when `invalidCount === 0` |
| `errors` | `Array<{index,message}>` | Per-entry validation failures |
| `validCount` | `number` | Count of entries that passed |
| `invalidCount` | `number` | Count of entries that failed |
## Implementation Notes
All fields in `PasswordEntry` that are optional in the source format are typed as optional here rather than defaulting to empty strings. This prevents parsers from silently papering over missing data and lets consumers distinguish between a genuinely empty notes field and a format that does not export notes at all. The `type` field maps Bitwarden's item types and KeePass group conventions into a shared four-value enum; entries without a recognisable type are left as `undefined`.

View File

@@ -0,0 +1,46 @@
/**
* Core types for the password-formats package.
*/
/** Supported password manager export formats. */
export type PasswordFormat =
| "apple"
| "bitwarden"
| "chrome"
| "keepass"
| "unknown";
/** A normalized password entry extracted from any supported format. */
export interface PasswordEntry {
id?: string;
title: string;
username?: string;
password: string;
url?: string;
notes?: string;
folder?: string;
favorite?: boolean;
/** ISO 8601 datetime string */
createdAt?: string;
/** ISO 8601 datetime string */
updatedAt?: string;
tags?: string[];
customFields?: Record<string, string>;
type?: "login" | "card" | "note" | "identity";
}
/** Options for parsing password manager export files. */
export interface ParseOptions {
/** If true, skip invalid entries instead of throwing. Default: false. */
skipInvalid?: boolean;
/** Maximum number of entries to parse. */
maxEntries?: number;
}
/** Result of validating a list of PasswordEntry objects. */
export interface ValidationResult {
valid: boolean;
errors: Array<{ index: number; message: string }>;
validCount: number;
invalidCount: number;
}

View File

@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("has no @suaveplan/* runtime dependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual([]);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@opentelemetry/api",
"fast-xml-parser",
"fflate",
"papaparse",
"tslib",
"zod",
]);
});
});

View File

@@ -0,0 +1,41 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
const BANNED: ReadonlyArray<RegExp> = [
/\bnode:fs\b/,
/\bnode:path\b/,
/\bnode:url\b/,
/\bprocess\./,
/\bBuffer\b/,
/\bdocument\b/,
/\bwindow\b/,
];
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
}
}
describe("no-disallowed", () => {
it("src/ does not reference Node or browser built-ins", {
timeout: 5000,
}, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
const content = readFileSync(file, "utf8");
for (const pattern of BANNED) {
if (pattern.test(content)) {
hits.push(`${file} matched ${pattern}`);
}
}
}
expect(hits).toEqual([]);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("exports has at least 7 entries", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports?: Record<string, unknown> };
expect(pkg.exports).toBeDefined();
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(7);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/password-formats",
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
external: [
"@opentelemetry/api",
"fast-xml-parser",
"fflate",
"papaparse",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});