diff --git a/CLAUDE.md b/CLAUDE.md index bb62e43..f80bd80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,9 +35,19 @@ BrowserExtractor.extract() → ExportData → to_legacy_dict() ├─── ZenBookmarkImporter → places.sqlite (moz_bookmarks) ├─── FaviconImporter → favicons.sqlite + inline data URIs ├─── HistoryImporter → places.sqlite (moz_places, moz_historyvisits) - └─── CookiesImporter → cookies.sqlite (incl. per-container dupes) + ├─── CookiesImporter → cookies.sqlite (incl. per-container dupes) + └─── ZenPasswordImporter → logins.json + key4.db (via Zen's own importer) ``` +The password step is the one writer that does not hand-write a Zen file. +Zen encrypts logins with its NSS key, so `ZenPasswordImporter` briefly +launches Zen headless with Marionette and drives Zen's built-in +`ChromeProfileMigrator`, restricted to passwords, against the Chromium +source's `User Data`. No password plaintext passes through browser2zen, +and nothing reverse-engineers Zen's credential crypto. It runs only for +Chromium sources and refuses while Zen is open, since Zen must own the +login store during the write. + ### Source extractors (`src/extractors/`) | File | Source | Notes | diff --git a/README.md b/README.md index b1cc61a..8b00d6f 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ your profile, then click **Recheck** in browser2zen and continue. | **Favicons** | The source's cached icons are inlined so tabs show their icons immediately, with no waiting for refetch. | | **History** | Optional. Browsing history with original timestamps is copied over. | | **Login state** | Optional. Chromium-format cookies are decrypted (via macOS Keychain or Windows DPAPI, depending on platform) and re-encrypted into Zen so you stay logged in to Gmail, Twitter, and the rest. | +| **Saved passwords** | Optional, Chromium sources only. Zen opens for a few seconds and imports your saved logins through its own importer, so each password is re-encrypted with Zen's key. macOS asks once to let Zen read the source's Keychain entry. | Every step writes a timestamped backup beside your Zen profile before it changes anything, and source-browser data is read-only. The Backups screen diff --git a/app/bridge.py b/app/bridge.py index 265c141..eb874cb 100644 --- a/app/bridge.py +++ b/app/bridge.py @@ -627,4 +627,5 @@ def _parse_options(opts_json: str) -> MigrationOptions: include_open_tabs=bool(data.get("includeOpenTabs", False)), include_history=bool(data.get("includeHistory", False)), include_cookies=bool(data.get("includeCookies", False)), + include_passwords=bool(data.get("includePasswords", False)), ) diff --git a/app/env_check.py b/app/env_check.py index 25b4e13..5823079 100644 --- a/app/env_check.py +++ b/app/env_check.py @@ -266,6 +266,41 @@ def is_zen_running() -> bool: return False +# ---------- zen binary ---------- + + +def zen_binary_path() -> Path | None: + """The Zen executable this machine would launch, or ``None``. + + The password step drives Zen's own importer, so it needs the binary + rather than the profile. Mirrors the running-process path lists so the + two can never disagree about where Zen lives. + """ + if sys.platform == "darwin": + for p in _ZEN_PROCESS_PATHS: + if Path(p).is_file(): + return Path(p) + return None + if os.name == "nt": + home = Path.home() + candidates: list[Path] = [] + for prof in list_zen_profiles(): + root = prof.path.parent.parent + candidates += [root / "zen.exe", root / "zen-bin.exe"] + candidates += [ + home / "AppData/Local/zen/zen.exe", + Path("C:/Program Files/Zen Browser/zen.exe"), + Path("C:/Program Files (x86)/Zen Browser/zen.exe"), + ] + return next((c for c in candidates if c.is_file()), None) + for rel in ("zen/zen", "zen-bin"): + cand = Path("/usr/lib") / rel + if cand.is_file(): + return cand + found = shutil.which("zen") + return Path(found) if found else None + + # ---------- previous migration marker ---------- diff --git a/app/frontend/app.js b/app/frontend/app.js index eb422d1..c7b249b 100644 --- a/app/frontend/app.js +++ b/app/frontend/app.js @@ -67,12 +67,14 @@ const state = { includeOpenTabs: true, includeHistory: false, includeCookies: false, + includePasswords: false, excludedSpaces: [], }, steps: [], stepLabels: {}, stepStates: {}, stepSummaries: {}, + stepErrors: {}, logLines: [], pollHandle: null, startedAt: 0, @@ -484,6 +486,10 @@ function renderPreview(p) { `Copy ~${formatRows(p.historyRowsEstimate)} history rows`)); toggles.appendChild(makeToggle("includeCookies", "Cookies & login state", `Copy ~${formatRows(p.cookiesEstimate)} cookies (Keychain prompt)`)); + if (p.passwordsEstimate > 0) { + toggles.appendChild(makeToggle("includePasswords", "Saved passwords", + `Import ~${formatRows(p.passwordsEstimate)} logins (Zen opens briefly, Keychain prompt)`)); + } toggles.appendChild(makeToggle("foldersCollapsed", "Collapse folders", "Imported folders start collapsed")); } @@ -626,6 +632,11 @@ const STEP_ICON_BUILDERS = { svgChild(s, "path", {d: "M14 9 L14 12", "stroke-linecap": "round"}); svgChild(s, "path", {d: "M16 9 L16 11.5", "stroke-linecap": "round"}); }, + passwords: (s) => { + svgChild(s, "circle", {cx: 6, cy: 9, r: 3.5}); + svgChild(s, "path", {d: "M9 9 L16 9 L16 12", "stroke-linecap": "round"}); + svgChild(s, "path", {d: "M13 9 L13 11.5", "stroke-linecap": "round"}); + }, finalize: (s) => svgChild(s, "path", {d: "M3 9 L7.5 13 L15 5", "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round"}), }; @@ -669,6 +680,8 @@ function stepSubtitle(step) { return "Copying browsing history into places.sqlite"; case "cookies": return `Decrypting ${src} cookies and writing to cookies.sqlite`; + case "passwords": + return `Opening Zen to import ${src} saved passwords`; case "finalize": return "Marking migration complete"; default: @@ -739,6 +752,7 @@ async function goToProgress() { state.stepLabels = meta.labels; state.stepStates = Object.fromEntries(state.steps.map(s => [s, "pending"])); state.stepSummaries = {}; + state.stepErrors = {}; state.logLines = []; state.activeStep = null; state.activeDetail = ""; @@ -784,7 +798,9 @@ function handleEvent(ev) { state.activeDetail = ""; state.activeProgress = null; } else if (ev.kind === "step_done") { - state.stepStates[ev.step] = "done"; + // A step that finished with a warning (e.g. some password profiles + // failed) stays visibly distinct from a clean success. + state.stepStates[ev.step] = (ev.summary && ev.summary.warning) ? "warn" : "done"; if (ev.summary) state.stepSummaries[ev.step] = ev.summary; if (state.activeStep === ev.step) { state.activeStep = null; @@ -793,6 +809,7 @@ function handleEvent(ev) { } } else if (ev.kind === "step_error") { state.stepStates[ev.step] = "error"; + state.stepErrors[ev.step] = ev.detail || ev.message || ""; } else { state.logLines.push({kind: ev.kind, message: ev.message, step: ev.step}); if (state.logLines.length > 500) state.logLines.shift(); @@ -812,6 +829,7 @@ function visibleSteps() { return state.steps.filter(s => { if (s === "history") return state.options.includeHistory; if (s === "cookies") return state.options.includeCookies; + if (s === "passwords") return state.options.includePasswords; if (s === "containers" || s === "sessions") return state.options.includeWorkspaces || state.options.includePinnedTabs; if (s === "bookmarks") return state.options.includeBookmarks; if (s === "favicons") return state.options.includeFavicons; @@ -833,7 +851,7 @@ function renderProgress() { const total = steps.length; const meta = $("progress-meta"); if (activeIdx >= 0) meta.textContent = `Step ${activeIdx + 1} of ${total}`; - else if (steps.every(s => state.stepStates[s] === "done")) meta.textContent = `${total} of ${total} done`; + else if (steps.every(s => ["done", "warn"].includes(state.stepStates[s]))) meta.textContent = `${total} of ${total} done`; else meta.textContent = "Working"; // unified timeline @@ -849,7 +867,7 @@ function renderProgress() { } function makeRow(step, st) { - const summary = (st === "done") ? stepSummaryText(step, state.stepSummaries[step]) : ""; + const summary = (st === "done" || st === "warn") ? stepSummaryText(step, state.stepSummaries[step]) : ""; return el("li", {class: "tl-step", dataset: {step, state: st}}, [ el("div", {class: "ico"}, [makeStepIcon(step)]), el("span", {class: "lbl", text: state.stepLabels[step] || step}), @@ -883,6 +901,13 @@ function stepSummaryText(step, s) { } if (step === "history") return `${formatRows(s.places_added ?? 0)} places · ${formatRows(s.visits_added ?? 0)} visits`; if (step === "cookies") return `${formatRows(s.imported ?? 0)} imported · ${formatRows(s.merged ?? 0)} merged`; + if (step === "passwords") { + if (s.skipped) return "skipped"; + const imported = `${formatRows(s.imported ?? 0)} passwords imported`; + const bad = Object.keys(s.failed || {}).length + (s.missing || []).length; + if (bad) return `${imported}, ${bad} ${bad === 1 ? "profile" : "profiles"} not imported`; + return imported; + } if (step === "sessions") { if (s.pinned == null && s.open == null) return s.ok ? "ok" : ""; const parts = []; @@ -936,7 +961,9 @@ async function finishOk(finalState) { summary.title = ""; summary.onclick = null; } else { - headline.textContent = "All set."; + const problems = renderDoneNotes(); + headline.textContent = problems ? "Finished with problems." : "All set."; + $("screen-done").dataset.outcome = problems ? "warn" : "ok"; const ext = state.stepSummaries.extract || {}; summary.textContent = ext.pinned ? `${ext.pinned} pinned tabs across ${ext.spaces} spaces. Backups saved next to your Zen profile.` @@ -946,6 +973,10 @@ async function finishOk(finalState) { summary.onclick = null; } + if (isBackup) { + $("screen-done").dataset.outcome = "ok"; + $("done-notes").style.display = "none"; + } launch.style.display = isBackup ? "none" : ""; backupsSection.style.display = isBackup ? "none" : ""; $("done-back").style.display = isBackup ? "" : "none"; @@ -963,6 +994,22 @@ async function finishOk(finalState) { } } +// Lists warnings and failed steps on the Done screen so a partial +// migration never reads as a clean one. Returns how many it listed. +function renderDoneNotes() { + const list = $("done-notes"); clear(list); + for (const s of visibleSteps()) { + const label = state.stepLabels[s] || s; + let text = ""; + // step_error details come from format_exception_only; drop the class name. + if (state.stepStates[s] === "error") text = `${label} failed. ${(state.stepErrors[s] || "").replace(/^\w+: /, "")}`; + else if (state.stepStates[s] === "warn") text = (state.stepSummaries[s] || {}).notice || `${label} finished with warnings.`; + if (text) list.appendChild(el("li", {dataset: {step: s}, text: text.trim()})); + } + list.style.display = list.children.length ? "" : "none"; + return list.children.length; +} + $("done-launch").addEventListener("click", async () => { const api = Bridge(); if (!api) return; // Don't await quit_app — calling window.destroy() while a JS-Python promise @@ -1121,6 +1168,7 @@ function currentOptionsJson() { includeOpenTabs: state.options.includeOpenTabs, includeHistory: state.options.includeHistory, includeCookies: state.options.includeCookies, + includePasswords: state.options.includePasswords, }); } @@ -1524,6 +1572,7 @@ function startProgressPolling() { setScreen("progress"); state.stepStates = {}; state.stepSummaries = {}; + state.stepErrors = {}; state.logLines = []; state.activeStep = null; state.activeDetail = ""; diff --git a/app/frontend/index.html b/app/frontend/index.html index 4b6d4b3..5607b19 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -247,6 +247,7 @@

Migrating

All set.

Your browser setup now lives in Zen.

+
diff --git a/app/frontend/styles.css b/app/frontend/styles.css index 3173bf5..bc7ee28 100644 --- a/app/frontend/styles.css +++ b/app/frontend/styles.css @@ -661,6 +661,7 @@ body[data-platform="mac"] main { box-shadow: 0 0 12px rgba(94, 134, 255, 0.32); } .meter-segment[data-state="error"] { background: var(--error); } +.meter-segment[data-state="warn"] { background: var(--warn); } /* Timeline: one unified vertical list. Active row expands inline. */ .timeline { @@ -731,6 +732,10 @@ body[data-platform="mac"] main { flex: 0 0 auto; } +/* state: warn (finished, but part of the step did not land) */ +.tl-step[data-state="warn"] .ico { color: var(--warn); } +.tl-step[data-state="warn"] .summary { color: var(--warn); } + /* state: error */ .tl-step[data-state="error"] .ico { color: var(--error); } .tl-step[data-state="error"] .lbl { color: var(--error); } @@ -861,12 +866,26 @@ body[data-platform="mac"] main { animation: drawcheck 600ms 100ms cubic-bezier(0.2, 0, 0, 1) forwards; } @keyframes drawcheck { to { stroke-dashoffset: 0; } } +#screen-done[data-outcome="warn"] .check { background: var(--warn-soft); } +#screen-done[data-outcome="warn"] .check svg path { stroke: var(--warn); } #screen-done .summary { margin-top: var(--space-2); font-size: var(--fs-body); color: var(--text-secondary); max-width: 480px; } +#screen-done .notes { + list-style: none; + margin: var(--space-4) 0 0; + padding: var(--space-3) var(--space-4); + max-width: 540px; + text-align: left; + font-size: var(--fs-small); + color: var(--warn); + background: var(--warn-soft); + border-radius: var(--radius-sm); +} +#screen-done .notes li + li { margin-top: var(--space-2); } #screen-done .actions { margin-top: var(--space-6); display: flex; gap: var(--space-3); } #screen-done .backups { margin-top: var(--space-6); diff --git a/app/orchestrator.py b/app/orchestrator.py index 118da1e..d35d769 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -37,6 +37,7 @@ from extractors import ArcExtractor, BrowserExtractor # noqa: E402 from zen_bookmark_importer import ZenBookmarkImporter # noqa: E402 from zen_favicon_importer import FaviconImporter, _iter_pinned_urls # noqa: E402 +from zen_password_importer import ZenPasswordImporter # noqa: E402 from zen_sessions_importer import ZenSessionsImporter # noqa: E402 from zen_space_importer import ZenProfile as _SrcZenProfile from zen_space_importer import ZenSpaceImporter # noqa: E402 @@ -73,6 +74,7 @@ class PreviewReport: favicon_match_estimate: int # how many URLs Arc has cached icons for history_rows_estimate: int cookies_estimate: int + passwords_estimate: int @dataclass @@ -88,6 +90,7 @@ class MigrationOptions: include_open_tabs: bool = True include_history: bool = False include_cookies: bool = False + include_passwords: bool = False # ----------- step ordering used in the GUI's progress list (left to right) --- @@ -100,6 +103,7 @@ class MigrationOptions: "favicons", "history", "cookies", + "passwords", "finalize", ) @@ -113,6 +117,7 @@ class MigrationOptions: "favicons": "Importing favicons", "history": "Importing browsing history", "cookies": "Importing cookies", + "passwords": "Importing saved passwords", "finalize": "Finalizing", } @@ -164,6 +169,64 @@ def _step_label(step: str, source_display: str) -> str: } +# Passwords ride Zen's own importer, so its failure modes differ from +# cookies. ``{source}`` is filled with the active source display name. +_PASSWORD_ERROR_MESSAGES = { + "no_source_passwords": + "{source} has no saved passwords to import.", + "passwords_unsupported_platform": + "Password import only supports macOS and Windows.", + "passwords_source_unsupported": + "Saved-password import is only available for Chromium sources " + "(Arc, Dia, Chrome, Edge, Brave).", + "zen_binary_missing": + "The Zen application could not be found to run its password importer.", + "zen_login_store_missing": + "Launch Zen once so it creates its password store, then click Recheck.", + "zen_running": + "Close Zen before importing passwords; Zen must own its login store " + "during the write.", + "password_import_failed": + "Zen's password importer did not finish; passwords skipped.", + "no_profiles_imported": + "Zen could not import passwords from any {source} profile.", +} + +# Zen's per-profile errors can be long stack-ish strings; the UI shows a +# sentence per profile, so keep each one readable. +_PROFILE_ERROR_LIMIT = 160 + + +def _password_notice(summary: dict) -> str: + """Describe per-profile password outcomes in plain sentences. + + Returns an empty string for a clean import. Profile ids are the + source's on-disk directory names (``Default``, ``Profile 1``). + """ + profiles = summary.get("profiles") or {} + failed = summary.get("failed") or {} + missing = summary.get("missing") or [] + total = len(profiles) + len(failed) + len(missing) + sentences: list[str] = [] + if failed or missing: + if profiles: + sentences.append( + f"Imported {summary.get('imported', 0)} passwords from " + f"{len(profiles)} of {total} profiles." + ) + for pid, err in failed.items(): + err = err if len(err) <= _PROFILE_ERROR_LIMIT else err[:_PROFILE_ERROR_LIMIT] + "..." + sentences.append(f"{pid} failed ({err}).") + if missing: + sentences.append(f"Zen's importer did not find {', '.join(missing)}.") + elif summary.get("warning") == "no_passwords_imported": + sentences.append( + "No passwords were imported. Zen skips logins it already has, " + "and nothing imports if the Keychain prompt is denied." + ) + return " ".join(sentences) + + # ------------------------------------------------------------------ orchestrator class MigrationOrchestrator: @@ -234,6 +297,7 @@ def preview(self, opts: MigrationOptions) -> PreviewReport: favicon_match_estimate = self._estimate_favicons(all_urls) history_rows_estimate = self._estimate_history_rows() cookies_estimate = self._estimate_cookies() + passwords_estimate = self._estimate_passwords() return PreviewReport( spaces=space_summaries, @@ -244,6 +308,7 @@ def preview(self, opts: MigrationOptions) -> PreviewReport: favicon_match_estimate=favicon_match_estimate, history_rows_estimate=history_rows_estimate, cookies_estimate=cookies_estimate, + passwords_estimate=passwords_estimate, ) def _estimate_favicons(self, urls: list[str]) -> int: @@ -298,6 +363,24 @@ def _estimate_cookies(self) -> int: except Exception: return 0 + def _estimate_passwords(self) -> int: + try: + import sqlite3 + total = 0 + for db in self.source.login_data_paths(): + try: + conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=2.0) + n = conn.execute( + "SELECT COUNT(*) FROM logins WHERE blacklisted_by_user = 0" + ).fetchone()[0] + conn.close() + total += int(n or 0) + except Exception: + continue + return total + except Exception: + return 0 + # ---- migration ------------------------------------------------------ def migrate(self, opts: MigrationOptions) -> Iterator[ProgressEvent]: @@ -535,7 +618,12 @@ def _run(self, opts: MigrationOptions) -> Iterator[ProgressEvent]: self._error_step("cookies", exc) yield from self._drain_yield() - # 9: finalize ---------------------------------------------------- + # 9: passwords --------------------------------------------------- + if opts.include_passwords: + self._run_passwords_step(zen_profile) + yield from self._drain_yield() + + # 10: finalize --------------------------------------------------- self._start_step("finalize") try: (zen_profile / ".browser2zen-migrated").write_text( @@ -546,6 +634,54 @@ def _run(self, opts: MigrationOptions) -> Iterator[ProgressEvent]: self._done_step("finalize", message="Migration complete") yield from self._drain_yield() + def _run_passwords_step(self, zen_profile: Path) -> None: + self._start_step("passwords") + try: + summary = self._import_passwords(zen_profile) + except Exception as exc: + self._error_step("passwords", exc) + return + err = summary.get("error") + notice = _password_notice(summary) + if err in ("no_source_passwords", "passwords_source_unsupported"): + # Nothing to import is a clean skip, not a migration failure. + msg = _PASSWORD_ERROR_MESSAGES[err].format(source=self.source.display_name) + self._done_step("passwords", summary={"imported": 0, "skipped": msg}) + elif err: + msg = _PASSWORD_ERROR_MESSAGES.get( + err, f"Password import failed: {err}" + ).format(source=self.source.display_name) + self._error_step("passwords", RuntimeError(f"{msg} {notice}".strip())) + else: + if notice: + summary = {**summary, "notice": notice} + self._emit({"kind": "warn", "step": "passwords", "message": notice}) + self._done_step("passwords", summary=summary) + + def _import_passwords(self, zen_profile: Path) -> dict: + user_data = self.source.user_data_root() + login_dbs = self.source.login_data_paths() + if user_data is None or not login_dbs: + # Firefox/Safari have no Chromium login store Zen can read. + code = ("no_source_passwords" if user_data is not None + else "passwords_source_unsupported") + return {"error": code} + + from app.env_check import zen_binary_path + zen_binary = zen_binary_path() + if zen_binary is None: + return {"error": "zen_binary_missing"} + + importer = ZenPasswordImporter( + zen_profile=zen_profile, + zen_binary=zen_binary, + source_user_data=user_data, + profile_ids=[db.parent.name for db in login_dbs], + keychain_service=getattr(self.source, "keychain_service", "Arc Safe Storage"), + keychain_account=getattr(self.source, "keychain_account", "Arc"), + ) + return importer.import_passwords() + def _drain_yield(self) -> Iterator[ProgressEvent]: yield from self.bus.drain() @@ -585,4 +721,5 @@ def preview_to_dict(report: PreviewReport) -> dict: "faviconMatchEstimate": report.favicon_match_estimate, "historyRowsEstimate": report.history_rows_estimate, "cookiesEstimate": report.cookies_estimate, + "passwordsEstimate": report.passwords_estimate, } diff --git a/docs/screenshots/mock-bridge.js b/docs/screenshots/mock-bridge.js index 0d9a528..566efde 100644 --- a/docs/screenshots/mock-bridge.js +++ b/docs/screenshots/mock-bridge.js @@ -84,7 +84,16 @@ window.pywebview = { get_step_metadata: async () => ({ steps: [], labels: {} }), // Migration-side methods: tests don't drive these screens via the // happy path, but app.js may call them on screen entry. - preview: async () => ({ ok: true, spaces: [], folders: [], pinnedTabs: 0 }), + preview: async () => ({ + spaces: [ + { name: "Work", icon: "💼", pinnedCount: 8, openCount: 2, folderCount: 1, essentialCount: 2, color: [94, 134, 255] }, + { name: "Reading", icon: "📚", pinnedCount: 5, openCount: 1, folderCount: 0, essentialCount: 1, color: [120, 90, 220] }, + { name: "Hobby", icon: "🎧", pinnedCount: 4, openCount: 1, folderCount: 1, essentialCount: 2, color: [40, 170, 120] }, + ], + pinnedTotal: 17, openTotal: 4, folderTotal: 2, bookmarkTotal: 19, + faviconMatchEstimate: 15, historyRowsEstimate: 42000, + cookiesEstimate: 12000, passwordsEstimate: 320, + }), start_migration: async () => ({ ok: true }), start_zen_export: async () => ({ ok: true }), start_zen_restore: async () => ({ ok: true }), diff --git a/docs/screenshots/preview.png b/docs/screenshots/preview.png new file mode 100644 index 0000000..57b4ecc Binary files /dev/null and b/docs/screenshots/preview.png differ diff --git a/docs/screenshots/screens.spec.ts b/docs/screenshots/screens.spec.ts index de4a0d2..387979b 100644 --- a/docs/screenshots/screens.spec.ts +++ b/docs/screenshots/screens.spec.ts @@ -78,6 +78,19 @@ test("detect", async ({ page }) => { await shoot(page, "detect"); }); +test("preview", async ({ page }) => { + await bootHarness(page); + await page.locator("#welcome-go").click(); + await waitForActiveScreen(page, "source"); + await page.locator('.source-card[data-name="arc"]').click(); + await page.locator("#source-next").click(); + await waitForActiveScreen(page, "detect"); + await page.locator("#detect-next").click(); + await waitForActiveScreen(page, "preview"); + await page.locator("#toggles .toggle").last().waitFor({ state: "visible" }); + await shoot(page, "preview"); +}); + test("backup-mode", async ({ page }) => { await bootHarness(page); await page.locator("#welcome-backup").click(); diff --git a/src/extractors/arc.py b/src/extractors/arc.py index 3bc00b8..af37f7e 100644 --- a/src/extractors/arc.py +++ b/src/extractors/arc.py @@ -176,6 +176,16 @@ def cookie_db_paths(self) -> list[Path]: break return out + def login_data_paths(self) -> list[Path]: + root = self._user_data_dir() + if root is None: + return [] + return [profile / "Login Data" for profile in sorted(root.iterdir()) + if profile.is_dir() and (profile / "Login Data").is_file()] + + def user_data_root(self) -> Path | None: + return self._user_data_dir() + def local_state_paths(self) -> list[Path]: # Used on Windows for DPAPI key unwrap. home = Path.home() diff --git a/src/extractors/base.py b/src/extractors/base.py index 968a935..8834146 100644 --- a/src/extractors/base.py +++ b/src/extractors/base.py @@ -315,6 +315,17 @@ def local_state_paths(self) -> list[Path]: on Windows). Empty for Firefox/Safari and unused on macOS.""" return [] + def login_data_paths(self) -> list[Path]: + """Chromium ``Login Data`` SQLite files, one per source profile. + Empty for sources whose passwords Zen cannot import directly.""" + return [] + + def user_data_root(self) -> Path | None: + """The Chromium ``User Data`` root that holds the profile folders. + ``None`` for non-Chromium sources. Zen's own password importer reads + saved logins from here per profile.""" + return None + # ---- cookie decryption ---- def cookie_master_key(self) -> bytes: diff --git a/src/extractors/chromium.py b/src/extractors/chromium.py index 0852f34..b3c1f79 100644 --- a/src/extractors/chromium.py +++ b/src/extractors/chromium.py @@ -307,6 +307,17 @@ def local_state_paths(self) -> list[Path]: return [] return [root / "Local State"] if (root / "Local State").is_file() else [] + def login_data_paths(self) -> list[Path]: + out: list[Path] = [] + for prof in self.profile_paths(): + cand = prof / "Login Data" + if cand.is_file(): + out.append(cand) + return out + + def user_data_root(self) -> Path | None: + return self._user_data_dir() + # ---------- cookie key ---------- def cookie_master_key(self) -> bytes: diff --git a/src/zen_password_importer.py b/src/zen_password_importer.py new file mode 100644 index 0000000..41c6265 --- /dev/null +++ b/src/zen_password_importer.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +Chromium → Zen saved-password importer. + +Zen stores passwords encrypted with its own NSS key (``key4.db``), so the +only robust way to add logins is to let Zen encrypt them. This importer +briefly launches Zen headless with Marionette and runs Zen's built-in +``ChromeProfileMigrator`` pointed at the source browser's ``User Data`` +directory, restricted to passwords. Zen reads the source ``Login Data``, +decrypts each entry through the OS keystore (macOS Keychain / +Windows DPAPI), and writes it back encrypted with the target profile's +key. No password plaintext ever passes through this process, and nothing +here reverse-engineers Zen's credential crypto. + +The step is skipped cleanly for non-Chromium sources (``user_data_root`` +is ``None``) and refuses to run while Zen is open, since Zen must own the +login store during the write. + +Backups of ``logins.json``, ``logins-backup.json`` and ``key4.db`` are +written beside the profile before anything changes, unless ``dry_run``. +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import socket +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + +logger = logging.getLogger(__name__) + + +# Resource-type bit for passwords in Zen's MigrationUtils. Stable across +# Firefox/Zen releases; asserted in-browser below so a change surfaces +# loudly rather than silently importing the wrong data. +_PASSWORDS_RESOURCE_TYPE = 0x10 + +# Launch env that lets Marionette reach Zen's privileged chrome context. +_SYSTEM_ACCESS_ENV = "MOZ_REMOTE_ALLOW_SYSTEM_ACCESS" + +_LAUNCH_TIMEOUT_S = 90.0 +_QUIT_TIMEOUT_S = 30.0 + + +# The chrome-context script Zen runs. It receives the plan as JSON and +# returns only counts, never a username or password. ``ChromeProfileMigrator`` +# does the decrypt/encrypt internally. +_IMPORT_JS = r""" +const done = arguments[arguments.length - 1]; +const plan = JSON.parse(arguments[0]); +(async () => { + const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs"); + const { MigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MigrationUtils.sys.mjs"); + + const PASSWORDS = MigrationUtils.resourceTypes.PASSWORDS; + if (PASSWORDS !== plan.passwordsType) { + return { fatal: "passwords resource type changed: " + PASSWORDS }; + } + + const m = new ChromeProfileMigrator(); + m._chromeUserDataPath = plan.userData; + m._keychainServiceName = plan.keychainService; + m._keychainAccountName = plan.keychainAccount; + + const before = (await Services.logins.getAllLogins()).length; + const sourceProfiles = await m.getSourceProfiles(); + const byId = new Map(sourceProfiles.map(p => [p.id, p])); + const report = { before, perProfile: {}, missing: [] }; + + for (const id of plan.profileIds) { + const profile = byId.get(id); + if (!profile) { report.missing.push(id); continue; } + const countBefore = (await Services.logins.getAllLogins()).length; + try { + await m.migrate(PASSWORDS, false, profile); + } catch (e) { + report.perProfile[id] = { error: String(e) }; + continue; + } + const countAfter = (await Services.logins.getAllLogins()).length; + report.perProfile[id] = { added: countAfter - countBefore }; + } + + report.after = (await Services.logins.getAllLogins()).length; + report.imported = report.after - before; + return report; +})().then(done, e => done({ fatal: String(e), stack: e && e.stack })); +""" + + +def summarize_report(report: dict) -> dict: + """Fold Zen's in-browser report into the result the UI renders. + + Every requested profile lands in exactly one bucket. ``profiles`` maps + a migrated profile to the logins it added, ``failed`` maps a profile + whose migration threw to Zen's error, and ``missing`` lists profiles + Zen's migrator did not find. Partial outcomes carry a ``warning`` and a + run where no profile migrated carries an ``error``, so a partial import + never reads as a clean success. + """ + per = report.get("perProfile") or {} + profiles = { + str(pid): int(v.get("added", 0)) + for pid, v in per.items() if "error" not in v + } + failed = {str(pid): str(v["error"]) for pid, v in per.items() if "error" in v} + missing = [str(pid) for pid in report.get("missing") or []] + result: dict = { + "imported": int(report.get("imported", 0)), + "profiles": profiles, + "failed": failed, + "missing": missing, + } + if not profiles: + result["error"] = "no_profiles_imported" + elif failed or missing: + result["warning"] = "partial_import" + elif result["imported"] == 0: + # Every profile ran but nothing landed. Most often the Keychain or + # DPAPI prompt was declined, or Zen already had every login. + result["warning"] = "no_passwords_imported" + return result + + +class ZenPasswordImporter: + """Import Chromium-format saved passwords into a Zen profile.""" + + def __init__( + self, + zen_profile: Path, + zen_binary: Path, + source_user_data: Path, + profile_ids: list[str], + *, + keychain_service: str = "Arc Safe Storage", + keychain_account: str = "Arc", + dry_run: bool = False, + ): + self.zen_profile = Path(zen_profile) + self.zen_binary = Path(zen_binary) + self.source_user_data = Path(source_user_data) + self.profile_ids = list(profile_ids) + self.keychain_service = keychain_service + self.keychain_account = keychain_account + self.dry_run = dry_run + + # ---- guards ---- + + def _zen_is_running_on_profile(self) -> bool: + lock = self.zen_profile / ".parentlock" + if not lock.exists(): + return False + if os.name == "nt": + # Windows holds the file open exclusively while Zen runs, so a + # rename to itself fails only then. + try: + lock.rename(lock) + return False + except OSError: + return True + import fcntl + + try: + fd = os.open(lock, os.O_RDWR) + except OSError: + return False + try: + fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.lockf(fd, fcntl.LOCK_UN) + return False + except OSError: + return True + finally: + os.close(fd) + + # ---- backups ---- + + def _backup(self) -> Path | None: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + dest = self.zen_profile / f"browser2zen-passwords-backup-{stamp}" + made = False + for name in ("logins.json", "logins-backup.json", "key4.db"): + src = self.zen_profile / name + if src.is_file(): + dest.mkdir(exist_ok=True) + shutil.copy2(src, dest / name) + made = True + return dest if made else None + + def _count_existing_logins(self) -> int: + store = self.zen_profile / "logins.json" + if not store.is_file(): + return 0 + try: + return len(json.loads(store.read_text(encoding="utf-8")).get("logins", [])) + except Exception: + return 0 + + # ---- marionette ---- + + @staticmethod + def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + def _run_in_zen(self, plan: dict, port: int) -> dict: + deadline = time.time() + _LAUNCH_TIMEOUT_S + sock: socket.socket | None = None + while sock is None: + try: + sock = socket.create_connection(("127.0.0.1", port)) + except OSError: + if time.time() > deadline: + raise TimeoutError("Zen Marionette did not accept a connection") + time.sleep(0.5) + try: + sock.settimeout(_LAUNCH_TIMEOUT_S) + self._recv(sock) # server "hello" + self._cmd(sock, "WebDriver:NewSession", + {"capabilities": {"alwaysMatch": {"timeouts": {"script": 300000}}}}) + self._cmd(sock, "Marionette:SetContext", {"value": "chrome"}) + res = self._cmd(sock, "WebDriver:ExecuteAsyncScript", + {"script": _IMPORT_JS, "args": [json.dumps(plan)]}) + report = res.get("value", res) if isinstance(res, dict) else res + try: + self._cmd(sock, "Marionette:Quit", {"flags": ["eAttemptQuit"]}) + except Exception: + pass + return report + finally: + sock.close() + + _msg_id = 0 + + def _cmd(self, sock: socket.socket, name: str, params: dict) -> dict: + self._msg_id += 1 + body = json.dumps([0, self._msg_id, name, params]).encode() + sock.sendall(str(len(body)).encode() + b":" + body) + _, _, err, res = self._recv(sock) + if err: + raise RuntimeError(f"{name}: {err}") + return res + + @staticmethod + def _recv(sock: socket.socket) -> list: + buf = b"" + while b":" not in buf: + chunk = sock.recv(1) + if not chunk: + raise ConnectionError("Marionette closed the connection") + buf += chunk + length_str, rest = buf.split(b":", 1) + length = int(length_str) + while len(rest) < length: + chunk = sock.recv(length - len(rest)) + if not chunk: + raise ConnectionError("Marionette closed the connection") + rest += chunk + return json.loads(rest) + + # ---- temp prefs ---- + + def _apply_temp_prefs(self, port: int) -> str | None: + """Write Marionette prefs into ``user.js``; return prior contents.""" + user_js = self.zen_profile / "user.js" + prior = user_js.read_text(encoding="utf-8") if user_js.is_file() else None + lines = (prior or "") + lines += ( + '\nuser_pref("remote.prefs.recommended", false);' + f'\nuser_pref("marionette.port", {port});\n' + ) + user_js.write_text(lines, encoding="utf-8") + return prior + + def _restore_prefs(self, prior: str | None) -> None: + user_js = self.zen_profile / "user.js" + if prior is None: + user_js.unlink(missing_ok=True) + else: + user_js.write_text(prior, encoding="utf-8") + # Zen copies user.js values into prefs.js on launch, so strip the two + # we injected or they persist after the run. + prefs = self.zen_profile / "prefs.js" + if prefs.is_file(): + kept = [ + ln for ln in prefs.read_text(encoding="utf-8").splitlines(keepends=True) + if '"marionette.port"' not in ln + and '"remote.prefs.recommended"' not in ln + ] + prefs.write_text("".join(kept), encoding="utf-8") + + # ---- orchestration ---- + + def import_passwords(self) -> dict: + result: dict = {"imported": 0, "profiles": {}, "failed": {}, "missing": []} + + # Zen's Chromium importer decrypts through the macOS Keychain or + # Windows DPAPI; there is no supported keystore path elsewhere. + if sys.platform not in ("darwin", "win32"): + result["error"] = "passwords_unsupported_platform" + return result + if not self.profile_ids or not self.source_user_data.is_dir(): + result["error"] = "no_source_passwords" + return result + if not self.zen_binary.is_file(): + result["error"] = "zen_binary_missing" + return result + if not (self.zen_profile / "key4.db").is_file(): + # Fresh profile that Zen has never launched has no login key yet. + result["error"] = "zen_login_store_missing" + return result + if self._zen_is_running_on_profile(): + result["error"] = "zen_running" + return result + + existing = self._count_existing_logins() + if self.dry_run: + result["would_import_from"] = list(self.profile_ids) + result["existing_logins"] = existing + return result + + result["backup"] = str(self._backup() or "") + + plan = { + "userData": str(self.source_user_data), + "profileIds": self.profile_ids, + "keychainService": self.keychain_service, + "keychainAccount": self.keychain_account, + "passwordsType": _PASSWORDS_RESOURCE_TYPE, + } + port = self._free_port() + prior_prefs = self._apply_temp_prefs(port) + proc: subprocess.Popen | None = None + try: + env = dict(os.environ, **{_SYSTEM_ACCESS_ENV: "1"}) + proc = subprocess.Popen( + [str(self.zen_binary), "--marionette", "--headless", + "-no-remote", "-profile", str(self.zen_profile)], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + report = self._run_in_zen(plan, port) + self._wait_for_exit(proc) + except Exception as exc: + logger.error("Password import failed: %s", exc) + result["error"] = "password_import_failed" + if proc is not None: + self._terminate(proc) + return result + finally: + self._restore_prefs(prior_prefs) + + if isinstance(report, dict) and report.get("fatal"): + logger.error("Zen password import error: %s", report.get("fatal")) + result["error"] = "password_import_failed" + return result + + if not isinstance(report, dict): + result["error"] = "password_import_failed" + return result + return {**summarize_report(report), "backup": result["backup"]} + + def _wait_for_exit(self, proc: subprocess.Popen) -> None: + try: + proc.wait(timeout=_QUIT_TIMEOUT_S) + except subprocess.TimeoutExpired: + self._terminate(proc) + + @staticmethod + def _terminate(proc: subprocess.Popen) -> None: + try: + proc.terminate() + proc.wait(timeout=10) + except Exception: + try: + proc.kill() + except Exception: + pass diff --git a/tests/test_password_importer.py b/tests/test_password_importer.py new file mode 100644 index 0000000..4597b38 --- /dev/null +++ b/tests/test_password_importer.py @@ -0,0 +1,439 @@ +"""Tests for the saved-password migration path. + +These never launch Zen. They cover the extractor data source, the +preview estimate, the importer's guards and backups, and the +non-Chromium skip. The actual in-browser import is exercised manually +against a throwaway profile, since it needs a real Zen binary. +""" + +from __future__ import annotations + +import sqlite3 +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import pytest + + +def _make_login_data(path: Path, *, usable: int, blacklisted: int = 0) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.execute( + "CREATE TABLE logins (origin_url TEXT, username_value TEXT, " + "password_value BLOB, blacklisted_by_user INTEGER)" + ) + rows = [("https://a.test", "u", b"x", 0)] * usable + rows += [("https://b.test", "", b"", 1)] * blacklisted + conn.executemany("INSERT INTO logins VALUES (?,?,?,?)", rows) + conn.commit() + conn.close() + + +# ---- extractor data source ---- + +def test_chromium_login_data_and_root(chrome_home): + from extractors import ChromeExtractor + + ext = ChromeExtractor() + root = ext.user_data_root() + assert root is not None + profile = ext.profile_paths()[0] + _make_login_data(profile / "Login Data", usable=3) + + login_dbs = ext.login_data_paths() + assert login_dbs == [profile / "Login Data"] + assert login_dbs[0].parent.name == profile.name + + +def test_arc_login_data_and_root(arc_home): + from extractors import ArcExtractor + + user_data = arc_home / "Library/Application Support/Arc/User Data" + _make_login_data(user_data / "Default" / "Login Data", usable=4) + _make_login_data(user_data / "Profile 1" / "Login Data", usable=1) + + ext = ArcExtractor() + assert ext.user_data_root() == user_data + assert {p.parent.name for p in ext.login_data_paths()} == {"Default", "Profile 1"} + + +def test_non_chromium_has_no_login_store(firefox_home): + from extractors import FirefoxExtractor, SafariExtractor + + for cls in (FirefoxExtractor, SafariExtractor): + assert cls().user_data_root() is None + assert cls().login_data_paths() == [] + + +def test_preview_estimate_counts_usable_logins(chrome_home, zen_profile): + from app.orchestrator import MigrationOptions, MigrationOrchestrator + from extractors import ChromeExtractor + + profile = ChromeExtractor().profile_paths()[0] + _make_login_data(profile / "Login Data", usable=5, blacklisted=2) + + o = MigrationOrchestrator(source=ChromeExtractor()) + preview = o.preview(MigrationOptions(zen_profile_path=zen_profile)) + assert preview.passwords_estimate == 5 + + +# ---- importer guards ---- + +@pytest.fixture +def macos(monkeypatch): + """Pin the platform the importer supports. + + The importer refuses anything but macOS and Windows, so guard tests + that are about a later check must say which platform they run on. + Without this, Linux CI stops at the platform guard first. + """ + monkeypatch.setattr(sys, "platform", "darwin") + + +def _importer(zen_profile: Path, source_user_data: Path, **kw): + from zen_password_importer import ZenPasswordImporter + + defaults = dict( + zen_profile=zen_profile, + zen_binary=Path(sys.executable), + source_user_data=source_user_data, + profile_ids=["Default"], + ) + defaults.update(kw) + return ZenPasswordImporter(**defaults) + + +def test_dry_run_reports_plan_without_launching(tmp_path, macos): + zen = tmp_path / "zen" + zen.mkdir() + (zen / "key4.db").write_bytes(b"x") + src = tmp_path / "User Data" + (src / "Default").mkdir(parents=True) + + res = _importer(zen, src, dry_run=True).import_passwords() + assert res["would_import_from"] == ["Default"] + assert "imported" in res and res["imported"] == 0 + + +def test_guard_no_source_passwords(tmp_path, macos): + zen = tmp_path / "zen" + zen.mkdir() + (zen / "key4.db").write_bytes(b"x") + src = tmp_path / "User Data" + src.mkdir() + res = _importer(zen, src, profile_ids=[]).import_passwords() + assert res["error"] == "no_source_passwords" + + +def test_guard_zen_binary_missing(tmp_path, macos): + zen = tmp_path / "zen" + zen.mkdir() + (zen / "key4.db").write_bytes(b"x") + src = tmp_path / "User Data" + (src / "Default").mkdir(parents=True) + res = _importer(zen, src, zen_binary=tmp_path / "nope").import_passwords() + assert res["error"] == "zen_binary_missing" + + +def test_guard_login_store_missing(tmp_path, macos): + zen = tmp_path / "zen" + zen.mkdir() + src = tmp_path / "User Data" + (src / "Default").mkdir(parents=True) + res = _importer(zen, src).import_passwords() + assert res["error"] == "zen_login_store_missing" + + +@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX fcntl lock") +def test_guard_zen_running_detects_lock(tmp_path, macos): + zen = tmp_path / "zen" + zen.mkdir() + (zen / "key4.db").write_bytes(b"x") + lock = zen / ".parentlock" + lock.write_bytes(b"") + ready = tmp_path / "ready" + holder = subprocess.Popen([ + sys.executable, "-c", + textwrap.dedent(f""" + import fcntl, os, time + fd = os.open({str(lock)!r}, os.O_RDWR) + fcntl.lockf(fd, fcntl.LOCK_EX) + open({str(ready)!r}, 'w').close() + time.sleep(30) + """), + ]) + try: + for _ in range(100): + if ready.exists(): + break + time.sleep(0.05) + src = tmp_path / "User Data" + (src / "Default").mkdir(parents=True) + res = _importer(zen, src).import_passwords() + assert res["error"] == "zen_running" + finally: + holder.terminate() + holder.wait(timeout=5) + + +@pytest.mark.parametrize("platform", ["linux", "freebsd14"]) +def test_guard_unsupported_platform_runs_first(tmp_path, monkeypatch, platform): + """Off macOS/Windows there is no keystore Zen can decrypt with, so the + importer stops before touching the profile, even in dry-run mode.""" + monkeypatch.setattr(sys, "platform", platform) + zen = tmp_path / "zen" + zen.mkdir() + (zen / "key4.db").write_bytes(b"x") + src = tmp_path / "User Data" + (src / "Default").mkdir(parents=True) + + res = _importer(zen, src, dry_run=True).import_passwords() + assert res["error"] == "passwords_unsupported_platform" + assert "would_import_from" not in res + assert sorted(p.name for p in zen.iterdir()) == ["key4.db"] + + +def test_guard_windows_is_supported(tmp_path, monkeypatch): + """Windows passes the platform guard and reaches the next check.""" + monkeypatch.setattr(sys, "platform", "win32") + zen = tmp_path / "zen" + zen.mkdir() + src = tmp_path / "User Data" + (src / "Default").mkdir(parents=True) + res = _importer(zen, src).import_passwords() + assert res["error"] == "zen_login_store_missing" + + +# ---- per-profile outcomes ---- + +def test_summarize_all_profiles_imported(): + from zen_password_importer import summarize_report + + res = summarize_report({ + "imported": 7, + "perProfile": {"Default": {"added": 5}, "Profile 1": {"added": 2}}, + "missing": [], + }) + assert res["profiles"] == {"Default": 5, "Profile 1": 2} + assert res["failed"] == {} and res["missing"] == [] + assert "warning" not in res and "error" not in res + + +def test_summarize_mixed_success_is_partial(): + from zen_password_importer import summarize_report + + res = summarize_report({ + "imported": 5, + "perProfile": {"Default": {"added": 5}, "Profile 1": {"error": "NS_ERROR_FAILURE"}}, + "missing": [], + }) + assert res["profiles"] == {"Default": 5} + assert res["failed"] == {"Profile 1": "NS_ERROR_FAILURE"} + assert res["warning"] == "partial_import" + assert "error" not in res + + +def test_summarize_missing_profile_is_partial(): + from zen_password_importer import summarize_report + + res = summarize_report({ + "imported": 3, + "perProfile": {"Default": {"added": 3}}, + "missing": ["Profile 2"], + }) + assert res["missing"] == ["Profile 2"] + assert res["warning"] == "partial_import" + + +def test_summarize_no_profile_migrated_is_error(): + from zen_password_importer import summarize_report + + res = summarize_report({ + "imported": 0, + "perProfile": {"Default": {"error": "boom"}}, + "missing": ["Profile 1"], + }) + assert res["error"] == "no_profiles_imported" + assert res["failed"] == {"Default": "boom"} + assert res["missing"] == ["Profile 1"] + + +def test_summarize_zero_imports_warns(): + from zen_password_importer import summarize_report + + res = summarize_report({ + "imported": 0, + "perProfile": {"Default": {"added": 0}}, + "missing": [], + }) + assert res["warning"] == "no_passwords_imported" + assert "error" not in res + + +def _password_step_events(monkeypatch, zen_profile, summary): + from app.orchestrator import MigrationOrchestrator + from extractors import ChromeExtractor + + o = MigrationOrchestrator(source=ChromeExtractor()) + monkeypatch.setattr(o, "_import_passwords", lambda _zen: summary) + o._run_passwords_step(zen_profile) + return list(o.bus.drain()) + + +def test_step_surfaces_mixed_success(chrome_home, zen_profile, monkeypatch): + events = _password_step_events(monkeypatch, zen_profile, { + "imported": 5, "profiles": {"Default": 5}, + "failed": {"Profile 1": "NS_ERROR_FAILURE"}, "missing": ["Profile 2"], + "warning": "partial_import", + }) + done = next(e for e in events if e["kind"] == "step_done") + notice = done["summary"]["notice"] + assert "5 passwords from 1 of 3 profiles" in notice + assert "Profile 1 failed (NS_ERROR_FAILURE)" in notice + assert "did not find Profile 2" in notice + assert done["summary"]["warning"] == "partial_import" + assert any(e["kind"] == "warn" and e["message"] == notice for e in events) + + +def test_step_surfaces_missing_profiles(chrome_home, zen_profile, monkeypatch): + events = _password_step_events(monkeypatch, zen_profile, { + "imported": 2, "profiles": {"Default": 2}, "failed": {}, + "missing": ["Profile 3", "Profile 4"], "warning": "partial_import", + }) + done = next(e for e in events if e["kind"] == "step_done") + assert "did not find Profile 3, Profile 4" in done["summary"]["notice"] + + +def test_step_surfaces_zero_imports(chrome_home, zen_profile, monkeypatch): + events = _password_step_events(monkeypatch, zen_profile, { + "imported": 0, "profiles": {"Default": 0}, "failed": {}, "missing": [], + "warning": "no_passwords_imported", + }) + done = next(e for e in events if e["kind"] == "step_done") + assert "No passwords were imported" in done["summary"]["notice"] + assert any(e["kind"] == "warn" for e in events) + + +def test_step_errors_when_no_profile_migrated(chrome_home, zen_profile, monkeypatch): + events = _password_step_events(monkeypatch, zen_profile, { + "imported": 0, "profiles": {}, "failed": {"Default": "boom"}, + "missing": [], "error": "no_profiles_imported", + }) + assert not any(e["kind"] == "step_done" for e in events) + err = next(e for e in events if e["kind"] == "step_error") + assert "any Chrome profile" in err["detail"] + assert "Default failed (boom)" in err["detail"] + + +def test_step_clean_import_has_no_notice(chrome_home, zen_profile, monkeypatch): + events = _password_step_events(monkeypatch, zen_profile, { + "imported": 4, "profiles": {"Default": 4}, "failed": {}, "missing": [], + }) + done = next(e for e in events if e["kind"] == "step_done") + assert "notice" not in done["summary"] + assert not any(e["kind"] == "warn" for e in events) + + +# ---- temp prefs + backup ---- + +def test_temp_prefs_roundtrip_without_existing_user_js(tmp_path): + zen = tmp_path / "zen" + zen.mkdir() + (zen / "prefs.js").write_text('user_pref("x", 1);\n', encoding="utf-8") + imp = _importer(zen, tmp_path) + + prior = imp._apply_temp_prefs(2828) + assert prior is None + body = (zen / "user.js").read_text() + assert '"marionette.port", 2828' in body + + # Simulate Zen persisting the injected prefs into prefs.js. + (zen / "prefs.js").write_text( + 'user_pref("x", 1);\n' + 'user_pref("marionette.port", 2828);\n' + 'user_pref("remote.prefs.recommended", false);\n', + encoding="utf-8", + ) + imp._restore_prefs(prior) + assert not (zen / "user.js").exists() + kept = (zen / "prefs.js").read_text() + assert "marionette.port" not in kept + assert "remote.prefs.recommended" not in kept + assert 'user_pref("x", 1);' in kept + + +def test_temp_prefs_preserves_existing_user_js(tmp_path): + zen = tmp_path / "zen" + zen.mkdir() + (zen / "user.js").write_text('user_pref("keep.me", true);\n', encoding="utf-8") + imp = _importer(zen, tmp_path) + + prior = imp._apply_temp_prefs(3000) + assert prior == 'user_pref("keep.me", true);\n' + imp._restore_prefs(prior) + assert (zen / "user.js").read_text() == 'user_pref("keep.me", true);\n' + + +def test_backup_copies_login_store(tmp_path): + zen = tmp_path / "zen" + zen.mkdir() + (zen / "logins.json").write_text("{}", encoding="utf-8") + (zen / "key4.db").write_bytes(b"k") + imp = _importer(zen, tmp_path) + + dest = imp._backup() + assert dest is not None and dest.is_dir() + assert (dest / "logins.json").is_file() + assert (dest / "key4.db").is_file() + + +# ---- zen binary discovery ---- + +def test_zen_binary_path_finds_macos_app(fake_home, monkeypatch, tmp_path): + import app.env_check as env_check + + fake = tmp_path / "Zen.app/Contents/MacOS/zen" + fake.parent.mkdir(parents=True) + fake.write_text("#!/bin/sh\n") + monkeypatch.setattr(env_check, "_ZEN_PROCESS_PATHS", (str(fake),)) + assert env_check.zen_binary_path() == fake + + +def test_zen_binary_path_none_when_absent(fake_home, monkeypatch): + import app.env_check as env_check + + monkeypatch.setattr(env_check, "_ZEN_PROCESS_PATHS", ("/nope/zen",)) + assert env_check.zen_binary_path() is None + + +# ---- orchestrator skip for non-Chromium ---- + +def test_orchestrator_skips_non_chromium_passwords(firefox_home, zen_profile): + from app.orchestrator import MigrationOrchestrator + from extractors import FirefoxExtractor + + o = MigrationOrchestrator(source=FirefoxExtractor()) + res = o._import_passwords(zen_profile) + assert res["error"] == "passwords_source_unsupported" + + +def test_orchestrator_chromium_reaches_importer(chrome_home, zen_profile, monkeypatch): + """A Chromium source must resolve the whole wiring (env_check import, + importer construction) and stop at a guard, never crash. Guards against + the bare-module import regression.""" + import app.env_check as env_check + from app.orchestrator import MigrationOrchestrator + from extractors import ChromeExtractor + + profile = ChromeExtractor().profile_paths()[0] + _make_login_data(profile / "Login Data", usable=2) + # Pin a resolvable binary and an empty login store so nothing launches. + monkeypatch.setattr(env_check, "zen_binary_path", lambda: Path(sys.executable)) + (zen_profile / "key4.db").unlink(missing_ok=True) + + o = MigrationOrchestrator(source=ChromeExtractor()) + res = o._import_passwords(zen_profile) + assert res["error"] == "zen_login_store_missing"