diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..514f237
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,20 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "husky": {
+ "version": "0.9.1",
+ "commands": [
+ "husky"
+ ],
+ "rollForward": false
+ },
+ "fallout.globaltool": {
+ "version": "10.4.0",
+ "commands": [
+ "fallout"
+ ],
+ "rollForward": true
+ }
+ }
+}
diff --git a/.fallout/.gitignore b/.fallout/.gitignore
new file mode 100644
index 0000000..36445e5
--- /dev/null
+++ b/.fallout/.gitignore
@@ -0,0 +1 @@
+/temp
diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json
new file mode 100644
index 0000000..472ce39
--- /dev/null
+++ b/.fallout/build.schema.json
@@ -0,0 +1,113 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "definitions": {
+ "Host": {
+ "type": "string",
+ "enum": [
+ "AppVeyor",
+ "AzurePipelines",
+ "Bamboo",
+ "Bitbucket",
+ "Bitrise",
+ "GitHubActions",
+ "GitLab",
+ "Jenkins",
+ "Rider",
+ "SpaceAutomation",
+ "TeamCity",
+ "Terminal",
+ "TravisCI",
+ "VisualStudio",
+ "VSCode"
+ ]
+ },
+ "ExecutableTarget": {
+ "type": "string",
+ "enum": [
+ "Compile",
+ "Test",
+ "TestLive"
+ ]
+ },
+ "Verbosity": {
+ "type": "string",
+ "description": "",
+ "enum": [
+ "Verbose",
+ "Normal",
+ "Minimal",
+ "Quiet"
+ ]
+ },
+ "FalloutBuild": {
+ "properties": {
+ "Continue": {
+ "type": "boolean",
+ "description": "Indicates to continue a previously failed build attempt"
+ },
+ "Help": {
+ "type": "boolean",
+ "description": "Shows the help text for this build assembly"
+ },
+ "Host": {
+ "description": "Host for execution. Default is 'automatic'",
+ "$ref": "#/definitions/Host"
+ },
+ "NoLogo": {
+ "type": "boolean",
+ "description": "Disables displaying the NUKE logo"
+ },
+ "Partition": {
+ "type": "string",
+ "description": "Partition to use on CI"
+ },
+ "Plan": {
+ "type": "boolean",
+ "description": "Shows the execution plan (HTML)"
+ },
+ "Profile": {
+ "type": "array",
+ "description": "Defines the profiles to load",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Root": {
+ "type": "string",
+ "description": "Root directory during build execution"
+ },
+ "Skip": {
+ "type": "array",
+ "description": "List of targets to be skipped. Empty list skips all dependencies",
+ "items": {
+ "$ref": "#/definitions/ExecutableTarget"
+ }
+ },
+ "Target": {
+ "type": "array",
+ "description": "List of targets to be invoked. Default is '{default_target}'",
+ "items": {
+ "$ref": "#/definitions/ExecutableTarget"
+ }
+ },
+ "Verbosity": {
+ "description": "Logging verbosity during build execution. Default is 'Normal'",
+ "$ref": "#/definitions/Verbosity"
+ },
+ "BuildProjectFile": {
+ "type": [
+ "null",
+ "string"
+ ],
+ "description": "Path to the build project (.csproj) relative to the repository root. Defaults to 'build/_build.csproj' when unset. Read by the Fallout global tool's in-tool runner."
+ }
+ }
+ }
+ },
+ "allOf": [
+ {},
+ {
+ "$ref": "#/definitions/FalloutBuild"
+ }
+ ]
+}
diff --git a/.fallout/parameters.json b/.fallout/parameters.json
new file mode 100644
index 0000000..1e78227
--- /dev/null
+++ b/.fallout/parameters.json
@@ -0,0 +1,3 @@
+{
+ "Solution": "BitwardenSharp.slnx"
+}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..e9823fd
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,54 @@
+# ------------------------------------------------------------------------------
+#
+#
+# This code was generated.
+#
+# - To turn off auto-generation set:
+#
+# [GitHubActions (AutoGenerate = false)]
+#
+# - To trigger manual generation invoke:
+#
+# fallout --generate-configuration GitHubActions_build --host GitHubActions
+#
+#
+# ------------------------------------------------------------------------------
+
+name: build
+
+on:
+ push:
+ branches:
+ - develop
+ - main
+ pull_request:
+ branches:
+ - develop
+ - main
+ - 'release/*'
+ - 'hotfix/*'
+ - 'support/*'
+
+jobs:
+ ubuntu-latest:
+ name: ubuntu-latest
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ - name: 'Cache: .fallout/temp, ~/.nuget/packages'
+ uses: actions/cache@v6
+ with:
+ path: |
+ .fallout/temp
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Setup: .NET SDK'
+ uses: actions/setup-dotnet@v6
+ with:
+ global-json-file: global.json
+ - name: 'Restore: dotnet tools'
+ run: dotnet tool restore
+ - name: 'Run: Test'
+ run: dotnet fallout Test
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..0d084d3
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,48 @@
+# ------------------------------------------------------------------------------
+#
+#
+# This code was generated.
+#
+# - To turn off auto-generation set:
+#
+# [GitHubActions (AutoGenerate = false)]
+#
+# - To trigger manual generation invoke:
+#
+# fallout --generate-configuration GitHubActions_publish --host GitHubActions
+#
+#
+# ------------------------------------------------------------------------------
+
+name: publish
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ ubuntu-latest:
+ name: ubuntu-latest
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ - name: 'Cache: .fallout/temp, ~/.nuget/packages'
+ uses: actions/cache@v6
+ with:
+ path: |
+ .fallout/temp
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Setup: .NET SDK'
+ uses: actions/setup-dotnet@v6
+ with:
+ global-json-file: global.json
+ - name: 'Restore: dotnet tools'
+ run: dotnet tool restore
+ - name: 'Run: Test'
+ run: dotnet fallout Test
+ env:
+ NuGetApiKey: ${{ secrets.NUGET_API_KEY }}
diff --git a/.gitignore b/.gitignore
index ce89292..20bf2e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -416,3 +416,22 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp
+
+# ── Vault data — never commit ────────────────────────────────────────────────
+# A `bw list items` dump is the entire vault in PLAINTEXT: every password, TOTP
+# seed and note. One careless `git add -A` publishes the lot, and git history
+# keeps it even after a later delete. The pre-commit hook in .husky/ is the
+# active guard; these patterns are the passive one.
+items.json
+folders.json
+*vault-dump*.json
+*vault-export*.json
+bw-export*.json
+*.bitwarden.json
+REVIEW.md
+report.json
+merge-log.json
+
+# Session key / master password must never reach disk in this repo
+.bw-session
+secrets.env
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 0000000..4452a1e
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,10 @@
+#!/usr/bin/env sh
+# Husky.NET-managed pre-commit guard. Sourcing husky.sh is optional (honors HUSKY=0
+# and re-execs under `sh -e`); the guard also runs standalone on a fresh clone
+# before `dotnet husky install` has regenerated _/.
+husky_sh="$(dirname -- "$0")/_/husky.sh"
+[ -f "$husky_sh" ] && . "$husky_sh"
+
+# GitHub's secret scanning and push protection are free for public repos only, and
+# this is a private repo on a free-tier org. This is the substitute.
+sh "$(dirname -- "$0")/scan-secrets.sh"
diff --git a/.husky/scan-secrets.sh b/.husky/scan-secrets.sh
new file mode 100755
index 0000000..6bcccbe
--- /dev/null
+++ b/.husky/scan-secrets.sh
@@ -0,0 +1,125 @@
+#!/usr/bin/env sh
+# ─────────────────────────────────────────────────────────────────────────────
+# Staged-content secret scanner.
+#
+# Why this exists: GitHub's own secret scanning and push protection are not
+# available here — they are free for public repositories only, and this is a
+# private repo on a free-tier org ("Secret scanning is not available for this
+# repository", HTTP 422). This hook is the substitute, in the same spirit as the
+# pre-push guard in the sibling repositories.
+#
+# What it is guarding against specifically: this repository's whole subject is a
+# password vault. A `bw list items` dump is every password, TOTP seed and note in
+# PLAINTEXT, and git keeps it in history even after a later delete. That is the
+# accident worth spending a hook on.
+#
+# Design rule: NO NOISE. A hook that cries wolf is a hook everyone bypasses, and
+# a habitually bypassed hook is worse than none. Every pattern below is either an
+# exact vendor token shape or a structure that has no innocent explanation.
+# Entropy heuristics are deliberately absent — they are the usual source of the
+# false positives that kill these things.
+#
+# Bypass once (and think about why): git commit --no-verify
+# ─────────────────────────────────────────────────────────────────────────────
+set -u
+
+RED='\033[0;31m'; YELLOW='\033[0;33m'; DIM='\033[2m'; OFF='\033[0m'
+findings=0
+
+report() {
+ findings=$((findings + 1))
+ printf "${RED}✋ %s${OFF}\n %s\n" "$1" "$2" >&2
+}
+
+# Staged files, added/copied/modified only — renames and deletions carry no new content.
+staged=$(git diff --cached --name-only --diff-filter=ACM)
+[ -z "$staged" ] && exit 0
+
+for file in $staged; do
+ # The scanner and its documentation necessarily contain the patterns themselves.
+ case "$file" in
+ .husky/scan-secrets.sh|.gitignore|docs/security*|*.md) continue ;;
+ esac
+
+ # ── 1. Filenames that are vault dumps by convention ──────────────────────
+ case "$(basename "$file")" in
+ items.json|folders.json|report.json|merge-log.json|REVIEW.md)
+ report "Vault dump staged: $file" \
+ "This is vault data, not source. Remove it: git restore --staged '$file'"
+ continue
+ ;;
+ esac
+
+ # Binary files have no text to scan.
+ git diff --cached --numstat -- "$file" | grep -q '^-' && continue
+
+ added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++')
+ [ -z "$added" ] && continue
+
+ # ── 2. A Bitwarden EncString ─────────────────────────────────────────────
+ # ".||" — Bitwarden's own ciphertext
+ # format. Nothing else looks like this; its presence means vault data.
+ if printf '%s' "$added" | grep -Eq '[0-9]\.[A-Za-z0-9+/=]{20,}\|[A-Za-z0-9+/=]{20,}\|[A-Za-z0-9+/=]{20,}'; then
+ report "Bitwarden EncString in $file" \
+ "That is encrypted vault content. It does not belong in source."
+ fi
+
+ # ── 3. The shape of a decrypted vault item ───────────────────────────────
+ # bw stamps every object it emits with "object":"item"/"folder". Combined with
+ # a password or TOTP field, this is a decrypted dump rather than a fixture.
+ if printf '%s' "$added" | grep -Eq '"object"[[:space:]]*:[[:space:]]*"(item|folder|cipherDetails)"' \
+ && printf '%s' "$added" | grep -Eq '"(password|totp|privateKey)"[[:space:]]*:[[:space:]]*"[^"]{4,}'; then
+ report "Decrypted vault item in $file" \
+ "Looks like 'bw list items' output with real secrets. Use a redacted fixture."
+ fi
+
+ # ── 4. Private keys ──────────────────────────────────────────────────────
+ if printf '%s' "$added" | grep -Eq -- '-----BEGIN [A-Z ]*PRIVATE KEY-----'; then
+ report "Private key block in $file" "Never commit a private key."
+ fi
+
+ # ── 5. Exact vendor token shapes ─────────────────────────────────────────
+ # Each of these is a documented, unambiguous prefix+length. No guessing.
+ if printf '%s' "$added" | grep -Eq '(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}'; then
+ report "GitHub token in $file" "Revoke it, then remove it from the change."
+ fi
+ if printf '%s' "$added" | grep -Eq 'AKIA[0-9A-Z]{16}'; then
+ report "AWS access key id in $file" "Revoke it immediately."
+ fi
+ if printf '%s' "$added" | grep -Eq 'xox[baprs]-[A-Za-z0-9-]{10,}'; then
+ report "Slack token in $file" "Revoke it immediately."
+ fi
+ if printf '%s' "$added" | grep -Eq '(sk|rk)_(live|test)_[A-Za-z0-9]{20,}'; then
+ report "Stripe key in $file" "Revoke it immediately."
+ fi
+ if printf '%s' "$added" | grep -Eq 'AIza[0-9A-Za-z_-]{35}'; then
+ report "Google API key in $file" "Revoke it immediately."
+ fi
+ # NuGet keys matter here: the sibling repos publish packages.
+ if printf '%s' "$added" | grep -Eq 'oy2[a-z0-9]{43}'; then
+ report "NuGet API key in $file" "Revoke it on nuget.org."
+ fi
+
+ # ── 6. An assigned BW_SESSION ────────────────────────────────────────────
+ # The session key decrypts the entire vault. Only flagged when it is being
+ # given a literal value — referring to the variable is normal and fine.
+ if printf '%s' "$added" | grep -Eq 'BW_SESSION[[:space:]]*=[[:space:]]*["'"'"']?[A-Za-z0-9+/]{40,}={0,2}'; then
+ report "Hard-coded BW_SESSION in $file" \
+ "That key decrypts the whole vault. Read it from the environment instead."
+ fi
+
+ # ── 7. A master password assigned in code ────────────────────────────────
+ if printf '%s' "$added" | grep -Eiq '(master_?password|masterpw)[[:space:]]*[=:][[:space:]]*["'"'"'][^"'"'"']{6,}'; then
+ report "Hard-coded master password in $file" "Never. Prompt for it."
+ fi
+done
+
+if [ "$findings" -gt 0 ]; then
+ printf "\n${YELLOW}%s finding(s). Commit blocked.${OFF}\n" "$findings" >&2
+ printf "${DIM} Genuinely a false positive? Bypass once: git commit --no-verify${OFF}\n" >&2
+ printf "${DIM} If a real secret already reached a commit, rotate it — deleting it later${OFF}\n" >&2
+ printf "${DIM} does not remove it from git history.${OFF}\n" >&2
+ exit 1
+fi
+
+exit 0
diff --git a/.husky/task-runner.json b/.husky/task-runner.json
new file mode 100644
index 0000000..2e9f728
--- /dev/null
+++ b/.husky/task-runner.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://alirezanet.github.io/Husky.Net/schema.json",
+ "tasks": []
+}
diff --git a/BitwardenSharp.slnx b/BitwardenSharp.slnx
new file mode 100644
index 0000000..df6d3ad
--- /dev/null
+++ b/BitwardenSharp.slnx
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..2887ab7
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,57 @@
+# Changelog
+
+All notable changes to this project are documented here.
+The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
+adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+- Duplicate detection across five categories, with only same-site and same-brand/family groups
+ treated as mergeable.
+- Merge engine: survivor-first, verified before any deletion, soft deletes to trash.
+- `bwsharp scan` and `bwsharp merge` on Spectre.Console; dry run by default.
+- `bwsharp scan --from ` for read-only analysis of a saved `bw list items` dump.
+- Architecture tests enforcing the hexagon and keeping process execution inside Infrastructure.
+- Avalonia desktop host: unlock screen and a three-pane vault browser (folder tree, item list,
+ detail pane), with passwords masked until revealed.
+- `AddBitwardenServe()` — a second `IVaultClient` adapter over the local Vault Management API
+ from `bw serve`, on a random loopback port owned as a child process. Used by the desktop host,
+ where one long-lived server beats a Node start-up per call.
+- `IVaultSession` port for unlock/lock, separate from `IVaultClient` so only the desktop host
+ depends on taking a master password.
+
+### Changed
+- Rewritten from the pre-1.0 prototype, which returned process exit codes rather than data and had
+ no domain model. That work is preserved on the `archive/v0-copilot` tag.
+
+### Added
+- Full item model: cards, identities, secure notes and SSH keys alongside logins, plus password
+ history, per-cipher `key`, `reprompt` and collection ids. Card numbers, CVVs, SSH private keys
+ and historical passwords all redact in `ToString` like login passwords already did.
+- Folder create/rename/delete and drag-and-drop of items and folders, planned through
+ `FolderPaths` so a rename carries its subtree and self-nesting is refused before any write.
+- Website icons from Bitwarden's icon service, cached on disk, with a coloured-initial fallback.
+ Off by one flag; see the README on what a lookup discloses.
+
+### Added (continued)
+- Duplicate queue and three-pane merge editor in the desktop app. The queue separates groups whose
+ decisions are cosmetic from those with a real credential conflict; the editor handles groups of
+ any size, per-property choices, per-element collection inclusion, and merging into a member or
+ a new item.
+
+### Fixed
+- Desktop app froze on launch. Server start-up was awaited with `GetAwaiter().GetResult()` from a
+ DI factory, which runs on the UI thread; the awaits inside start-up then needed the thread that
+ was blocked waiting for them. Start-up is lazy and asynchronous now, shutdown cancels-awaits-
+ reshuts rather than blocking, and a test asserts that resolving a service starts no process.
+- `bw serve` was left running after an abrupt exit, keeping an unauthenticated port onto an
+ unlocked vault open indefinitely. Cleanup now runs on POSIX signals as well as `ProcessExit`.
+- Several detail-pane sections bound `Count` (an int) to `IsVisible` (a bool), which Avalonia
+ reports as a binding error at runtime.
+
+### Security
+- Secrets are never passed as process arguments: the session key travels in the child environment
+ and the item payload for `bw edit` is piped to stdin. The prototype passed the master password
+ on the command line, where `ps` exposes it to every local user.
+- Records holding credentials redact them in `ToString`.
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..6031e1a
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,32 @@
+
+
+
+
+ net10.0
+ latest
+ enable
+ enable
+
+
+ $(MSBuildProjectName.Replace("-","_"))
+ true
+
+ Chrison Simtian
+ Homelab
+ https://github.com/Chrison-dev/BitwardenSharp
+ https://github.com/Chrison-dev/BitwardenSharp
+ git
+ MIT
+ README.md
+ true
+
+ embedded
+
+
+
+
+
+
+
+
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 0000000..b56e8d3
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..21d77c7
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,40 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 2a4b2ab..ffb0192 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,218 @@
-# Bitwarden
-Tools around bitwarden
+# BitwardenSharp
+
+Vault-management tooling for [Bitwarden](https://bitwarden.com) that the official CLI does not
+provide: finding duplicate logins, and merging them safely.
+
+`bw` is a fine transport and a poor toolkit. It has no merge, no bulk operations, no dry run, and
+every edit is a read → mutate → base64 → write round trip against an item it replaces wholesale.
+BitwardenSharp adds the missing layer on top of it.
+
+```
+dotnet tool install -g BitwardenSharp.Cli
+
+export BW_SESSION=$(bw unlock --raw)
+bwsharp scan # read-only; find and classify duplicates
+bwsharp merge EXACT-001 # dry run
+bwsharp merge EXACT-001 --apply # write
+```
+
+## What it finds
+
+A scan classifies every group by how safely it can be collapsed:
+
+| Category | Meaning | Merged? |
+|---|---|---|
+| `ExactDuplicate` | Same registrable domain, same username, same password | yes |
+| `RelatedDomain` | Same credentials across one brand's TLDs, or one service family | yes |
+| `CredentialConflict` | Same site and username, different passwords — one is stale | review |
+| `InfrastructureSharedCredential` | One login reused across distinct hosts | review |
+| `SameName` | Identical item name, credentials differ | review |
+
+Only the first two are ever merged, and a group in either is still refused when it carries a
+blocking warning — an attachment (which `bw` cannot move between items) or two differing TOTP
+seeds.
+
+**A shared password is not evidence of a duplicate.** Password reuse is common enough that in a
+real vault one password can cover hundreds of unrelated accounts, so matching credentials only
+ever promote a group that some stronger signal — same domain, same brand, same family — has
+already established. Grouping on credentials alone proposes deleting live accounts.
+
+## How a merge is safe
+
+Merging is `edit` + `delete`, and the ordering is the safety property:
+
+1. Re-read the survivor and every loser from the vault. A scan is a snapshot; acting on a stale
+ one could overwrite a change made since.
+2. Fold the losers onto the survivor — union the URIs, adopt an unambiguous TOTP seed, add custom
+ fields the survivor lacks, append distinct notes, fill an empty folder. Purely additive: the
+ survivor's own name, username and password are never overwritten.
+3. Write the survivor, then **read it back and verify**.
+4. Only then delete the losers, softly, to Bitwarden's trash.
+
+There is no point at which data exists in neither item. A failure anywhere leaves the losers in
+place and the operation simply re-runnable. Deletions stay restorable for 30 days.
+
+Dry run is the default; `--apply` is the only way to write.
+
+## Offline scanning
+
+`bwsharp scan --from items.json` runs the same analysis over a saved `bw list items` dump without
+unlocking anything. Useful for auditing an export, and for reproducing a scan against a fixed
+snapshot. The file-backed vault refuses every write.
+
+## Architecture
+
+An onion, with dependencies pointing inward only — enforced by tests in
+`tests/Architecture.Tests`, not by convention.
+
+```
+src/Domain the vault model, URI/eTLD+1 reduction, duplicate value types. Depends on nothing.
+src/Application duplicate detection, survivor selection, merge planning. Owns the ports.
+src/Infrastructure two adapters onto `bw`, plus the wire contracts.
+src/Presentation/Cli the `bwsharp` tool, on Spectre.Console.
+src/Presentation/Desktop the GUI, on Avalonia.
+```
+
+### Two transports
+
+Both implement the same `IVaultClient` port, and each host picks what suits it:
+
+| | `AddBitwardenCli()` | `AddBitwardenServe()` |
+|---|---|---|
+| How | one `bw` process per call | one long-lived `bw serve`, HTTP for everything |
+| Cost | ~0.5s Node start-up **per call** | ~1.5s once |
+| Exposure | none | an **unauthenticated** local port for its lifetime |
+| Used by | the CLI — one-shot, so no port is worth opening | the desktop app — outlives every call |
+
+The Vault Management API has no authentication of any kind: anything that can reach the port
+reads the whole vault while it is unlocked. The mitigations are structural — loopback only, a
+random ephemeral port rather than the well-known 8087, spawned as a child process and killed on
+dispose, so the window is exactly the app's lifetime.
+
+One shape to know about if you extend the serve adapter: `/status` nests its payload under
+`data.template`, while every other endpoint puts it directly in `data`. The envelope is not
+uniform.
+
+### Why it wraps `bw` rather than replacing it
+
+There is no public Bitwarden API for personal vault items — the documented `api.bitwarden.com`
+surface is organisation-scoped only (members, groups, collections, policies, events). The
+alternatives were to shell out to the official client, or to reimplement Bitwarden's client-side
+crypto against its internal endpoints. The latter means owning Argon2id/PBKDF2 derivation and
+AES-CBC-HMAC EncString handling against an unsupported, changeable API, in front of a password
+vault. Not a trade worth making.
+
+Note also that the official `Bitwarden.Sdk` NuGet package targets Secrets Manager, not personal
+vault ciphers.
+
+### Handling secrets
+
+Two rules hold throughout, because process arguments are world-readable via `ps`:
+
+- Arguments go through `ProcessStartInfo.ArgumentList`, never a joined, hand-escaped string.
+- **Secrets never become arguments.** The session key travels in the child's environment, and the
+ base64 item payload for `edit` — which contains the password in clear — is piped to stdin,
+ which `bw` accepts in place of the positional argument.
+
+`LoginDetails` and `CustomField` override the compiler-generated record `ToString` to redact,
+so a stray log line or exception message cannot leak a credential.
+
+## Desktop app
+
+```
+dotnet run --project src/Presentation/Desktop
+```
+
+Avalonia 12. Unlock screen, then a three-pane browser: the folder tree (rebuilt from Bitwarden's
+slash-separated flat names), the item list, and a detail pane. Passwords are masked until
+revealed, and revealing is per-view — never persisted.
+
+Two rules the desktop host lives by, both learned the hard way:
+
+- **Never block the UI thread on a task.** The first service is resolved on that thread, where
+ Avalonia has installed a `SynchronizationContext`. Blocking there to await server start-up
+ deadlocked the app before it drew its window — the awaits needed the thread that was waiting
+ for them. `BwServeConnection` starts the server on first *awaited* use, and
+ `ShutdownRequested` cancels itself, awaits, then shuts down for real. There is no
+ `.GetAwaiter().GetResult()` anywhere, and a test enforces that resolution starts nothing.
+- **The child must die with us.** A `bw serve` orphaned by a crash keeps an unauthenticated port
+ onto an unlocked vault open indefinitely, so cleanup is hooked on both `ProcessExit` and
+ `PosixSignalRegistration` for SIGTERM/SIGINT/SIGHUP. `ProcessExit` alone was observed not to
+ land in time.
+
+### Folders behave like a file explorer
+
+Create, rename, delete, and drag items or whole folders between nodes. Dropping on "No folder"
+unfiles items; dropping a folder on empty space moves it to the root.
+
+**Bitwarden has no folder hierarchy.** "Homelab/Proxmox" is a single folder whose *name* contains
+a slash — it is not a child of "Homelab", and "Homelab" need not exist at all. Clients render the
+implied tree; the storage is flat. Every tree operation is therefore a bulk rename: moving
+"Homelab" without rewriting "Homelab/Proxmox" would show the folder moving and its contents
+staying behind. `FolderPaths` plans the whole set of renames — deepest-first, so no two folders
+ever momentarily share a name — and refuses collisions and self-nesting before anything is
+written. Descendancy compares whole segments, so "Homelab2" is never dragged along with
+"Homelab".
+
+Deleting a folder unfiles its items rather than deleting them, and takes the subtree with it —
+otherwise "Homelab/Proxmox" would be stranded as a root folder with a slash in its name.
+
+### Website icons
+
+Items show their site's icon, falling back to a coloured initial. Bitwarden stores no icon on an
+item: clients derive one from the first URI and fetch it from a hosted service, which is why an
+item with no URI never has one anywhere.
+
+**Each lookup discloses a domain from your vault to that service**, and doing it for a whole
+vault hands over a list of the sites you hold accounts with — including private ones, where the
+hostname alone is information. Three things limit it: only the registrable domain is sent, never
+a path; results are cached on disk for a month, misses included, so a domain is asked about once;
+and the cache filenames are hashed so the directory is not itself a readable list. Set
+`AddBitwardenIcons(o => o.Enabled = false)` and nothing leaves the machine.
+
+### Duplicate queue and merge editor
+
+The **Duplicates** button opens a queue of every group the scanner found, each showing the merge
+it proposes. It splits them by whether they need you:
+
+- **Routine** — mergeable, and the members already agree on username and password, so every
+ decision is cosmetic: which name, which folder, which URIs. One click each, or approve the lot.
+- **Needs a decision** — a real credential conflict, or a blocking warning. These open the editor.
+
+That split is not arbitrary. On the vault this was built against, **the password differs in zero
+of the mergeable groups** — it cannot differ, because same-site and same-brand grouping both
+require identical credentials. The dangerous decision only exists in the handful of
+`CredentialConflict` groups, so routing everything else through a three-pane editor would be
+ceremony.
+
+The editor is three panes: the members on the left, one of them compared in the middle, and the
+resolved result on the right. The left is a rail rather than a single pane because a real vault
+has groups of three to five members and a strict two-pane layout has nowhere to put the rest; for
+a two-member group it reads as a plain side-by-side.
+
+Scalars take a value from either side or one you type. Collections — URIs, custom fields — are
+unioned with per-element checkboxes, because "additive" only means something for a collection:
+you cannot have two usernames. Identical rows are hidden by default, since most properties agree
+and showing them buries the ones that don't.
+
+A radio chooses what the result becomes: any member, or a brand-new item. **New item disables
+itself when any member holds an attachment** — the CLI cannot move one, so creating a third item
+and deleting the sources would destroy the file.
+
+### Next
+
+Retiring dead credentials, and an item template for API keys.
+
+## Building
+
+```
+./build.sh # compile + test
+./build.sh TestLive # only the tests that drive a real bw against an unlocked vault
+```
+
+The build is [Fallout](https://fallout.build); `.github/workflows/*.yml` is generated from
+`build/Build.CI.GitHubActions.cs` and must not be hand-edited.
+
+## Licence
+
+MIT.
diff --git a/build.cmd b/build.cmd
new file mode 100755
index 0000000..61a428a
--- /dev/null
+++ b/build.cmd
@@ -0,0 +1,6 @@
+:; set -eo pipefail
+:; SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
+:; exec "$SCRIPT_DIR/build.sh" "$@"
+
+@echo off
+powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" %*
diff --git a/build.ps1 b/build.ps1
new file mode 100755
index 0000000..1e7f33e
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,9 @@
+#!/usr/bin/env pwsh
+# Fallout build bootstrapper.
+# ./build.ps1 Compile # build the solution
+# ./build.ps1 Test # build + run unit tests (default)
+[CmdletBinding()]
+Param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $BuildArguments)
+$ErrorActionPreference = 'Stop'
+dotnet run --project "$PSScriptRoot/build/_build.csproj" -- @BuildArguments
+exit $LASTEXITCODE
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..2c9a9b9
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+set -eo pipefail
+dotnet run --project "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/build/_build.csproj" -- "$@"
diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs
new file mode 100644
index 0000000..5c7a92f
--- /dev/null
+++ b/build/Build.CI.GitHubActions.cs
@@ -0,0 +1,73 @@
+using Fallout.Common;
+using Fallout.Common.CI.GitHubActions;
+
+///
+/// The CI/CD definition — every workflow this repository has, declared as an attribute.
+///
+///
+///
+/// `.github/workflows/*.yml` is GENERATED from what follows. Hand-editing the YAML is
+/// silently undone by the next generation. Change an attribute, then regenerate:
+/// dotnet fallout --generate-configuration GitHubActions_<name> --host GitHubActions
+/// (or just build the _build project, which regenerates all of them).
+///
+///
+/// The build is defined in C#, not in YAML. A workflow provisions a runner and routes a channel;
+/// every step that does something invokes a Fallout target — so the same commands behave
+/// identically on a laptop and on a runner.
+///
+/// The branch model is GitFlow, matching the other Chrison-dev repositories.
+///
+
+// ── The gate ──────────────────────────────────────────────────────────────────
+//
+// Runs on every PR into a long-lived branch, and on every push to the two permanent ones.
+// Feature branches build nothing until a PR is opened.
+//
+// Deliberately NO path exclusions: the job name is the required status check, so a docs-only
+// PR filtered out here would wait forever on a check that never fires.
+[GitHubActions(
+ "build",
+ GitHubActionsImage.UbuntuLatest,
+ FetchDepth = 0,
+ OnPushBranches = new[] { DevelopBranch, MainBranch },
+ OnPullRequestBranches = new[]
+ {
+ DevelopBranch, MainBranch, ReleaseBranchPattern, HotfixBranchPattern, SupportBranchPattern,
+ },
+ InvokedTargets = new[] { nameof(Test) })]
+
+// ── Publish ───────────────────────────────────────────────────────────────────
+//
+// The three libraries and the `bwsharp` dotnet tool go to nuget.org on a release tag.
+// Tag-triggered rather than push-triggered: a version is published because it was tagged,
+// never because something landed on a branch.
+[GitHubActions(
+ "publish",
+ GitHubActionsImage.UbuntuLatest,
+ FetchDepth = 0,
+ OnPushTags = new[] { ReleaseTagPattern },
+ InvokedTargets = new[] { nameof(Test) },
+ ImportSecrets = new[] { nameof(NuGetApiKey) })]
+partial class Build
+{
+ /// Integration branch; everything lands here first.
+ const string DevelopBranch = "develop";
+
+ /// The trunk. Only release and hotfix merges reach it.
+ const string MainBranch = "main";
+
+ /// Short-lived stabilisation window cut from .
+ const string ReleaseBranchPattern = "release/*";
+
+ /// Short-lived urgent production fix cut from .
+ const string HotfixBranchPattern = "hotfix/*";
+
+ /// Long-lived maintenance line for a release has moved past.
+ const string SupportBranchPattern = "support/*";
+
+ /// Release tags are v1.2.3; publishing is driven by these, not by branches.
+ const string ReleaseTagPattern = "v*";
+
+ [Secret] readonly string NuGetApiKey;
+}
diff --git a/build/Build.cs b/build/Build.cs
new file mode 100644
index 0000000..33d0500
--- /dev/null
+++ b/build/Build.cs
@@ -0,0 +1,44 @@
+using Fallout.Common;
+using Fallout.Common.IO;
+using Fallout.Common.Tools.DotNet;
+using static Fallout.Common.Tools.DotNet.DotNetTasks;
+
+///
+/// Fallout build for BitwardenSharp — the targets. The CI/CD definition that invokes them lives in
+/// Build.CI.GitHubActions.cs, from which every .github/workflows/*.yml is GENERATED;
+/// never hand-edit those.
+///
+/// Tests that touch a real vault through the `bw` CLI are tagged [Trait("Category","Live")] and
+/// EXCLUDED from the default Test run — they need an unlocked vault and mutate real data.
+/// Run them deliberately: ./build.sh TestLive
+///
+partial class Build : FalloutBuild
+{
+ public static int Main() => Execute(x => x.Test);
+
+ AbsolutePath SolutionFile => RootDirectory / "BitwardenSharp.slnx";
+
+ Target Compile => _ => _
+ .Description("Build the solution")
+ .Executes(() => DotNetBuild(_ => _
+ .SetProjectFile(SolutionFile)
+ .SetConfiguration("Release")));
+
+ Target Test => _ => _
+ .Description("Run the unit and architecture tests (excludes live vault tests)")
+ .DependsOn(Compile)
+ .Executes(() => DotNetTest(_ => _
+ .SetProjectFile(SolutionFile)
+ .SetConfiguration("Release")
+ .SetFilter("Category!=Live")
+ .EnableNoBuild()));
+
+ Target TestLive => _ => _
+ .Description("Run ONLY the tests that drive a real `bw` CLI against an unlocked vault")
+ .DependsOn(Compile)
+ .Executes(() => DotNetTest(_ => _
+ .SetProjectFile(SolutionFile)
+ .SetConfiguration("Release")
+ .SetFilter("Category=Live")
+ .EnableNoBuild()));
+}
diff --git a/build/_build.csproj b/build/_build.csproj
new file mode 100644
index 0000000..bf8f637
--- /dev/null
+++ b/build/_build.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ false
+ _build
+ $(MSBuildProjectDirectory)/..
+ CS0649;CS0169;CA1050;CS8618
+
+
+
+
+
+
+
+
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..1e7fdfa
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestMinor"
+ }
+}
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 0000000..4d736c1
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/Application/Abstractions/IIconProvider.cs b/src/Application/Abstractions/IIconProvider.cs
new file mode 100644
index 0000000..80c2f52
--- /dev/null
+++ b/src/Application/Abstractions/IIconProvider.cs
@@ -0,0 +1,31 @@
+namespace BitwardenSharp.Application.Abstractions;
+
+///
+/// Supplies website icons for vault items.
+///
+///
+///
+/// Bitwarden does not store an icon on an item. Its clients derive one from the item's first URI
+/// and fetch it from a hosted icon service, which is why an item with no URI never has an icon in
+/// any Bitwarden client either.
+///
+///
+/// This leaks information. Asking the icon service for git.internal.example/icon.png
+/// tells that service the domain is in someone's vault, and doing it for every item hands over a
+/// list of the sites you hold accounts with. Implementations must therefore be switchable off,
+/// must request the registrable domain only, and should cache aggressively so a domain is asked
+/// for once rather than on every render.
+///
+///
+public interface IIconProvider
+{
+ /// Whether icons are being fetched at all.
+ bool IsEnabled { get; }
+
+ ///
+ /// The icon bytes for a domain, or null when there is none, when fetching is disabled, or
+ /// when the lookup failed. Callers show a placeholder for null rather than treating it as an
+ /// error — a missing icon is the normal case for anything self-hosted.
+ ///
+ Task GetIconAsync(string domain, CancellationToken cancellationToken = default);
+}
diff --git a/src/Application/Abstractions/IVaultClient.cs b/src/Application/Abstractions/IVaultClient.cs
new file mode 100644
index 0000000..1d67b6b
--- /dev/null
+++ b/src/Application/Abstractions/IVaultClient.cs
@@ -0,0 +1,84 @@
+using BitwardenSharp.Domain.Vault;
+
+namespace BitwardenSharp.Application.Abstractions;
+
+///
+/// The port onto a Bitwarden vault. Application code depends on this and never on a transport.
+///
+///
+/// There is no public Bitwarden API for personal vault items — the documented api.bitwarden.com
+/// surface is organisation-scoped only. Every implementation therefore goes through the official
+/// bw client in some form. Reimplementing Bitwarden's client-side crypto against the
+/// internal endpoints was considered and rejected: it means owning Argon2id/PBKDF2 derivation and
+/// AES-CBC-HMAC EncString handling against an unsupported, changeable API, in front of a password
+/// vault.
+///
+public interface IVaultClient
+{
+ /// Pulls the latest vault state from the server.
+ Task SyncAsync(CancellationToken cancellationToken = default);
+
+ /// Whether the vault is currently unlocked and usable.
+ Task GetStatusAsync(CancellationToken cancellationToken = default);
+
+ Task> GetItemsAsync(CancellationToken cancellationToken = default);
+
+ Task> GetFoldersAsync(CancellationToken cancellationToken = default);
+
+ Task GetItemAsync(string id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Replaces an item wholesale and returns what the vault stored. The Bitwarden CLI has no
+ /// partial update: the object sent is the object kept, so callers must pass a complete item.
+ ///
+ Task UpdateItemAsync(VaultItem item, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes an item. Soft by default, which moves it to the trash and keeps it restorable for
+ /// 30 days — the only undo a merge has.
+ ///
+ Task DeleteItemAsync(string id, bool permanent = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates an item and returns it as stored, with the id the vault assigned.
+ ///
+ ///
+ /// Used when a merge is resolved into a brand-new item rather than into one of its sources.
+ /// Note that attachments cannot be carried onto a created item — the CLI has no way to move
+ /// one — so a group holding an attachment cannot be merged this way at all.
+ ///
+ Task CreateItemAsync(VaultItem item, CancellationToken cancellationToken = default);
+
+ /// Creates a folder. The name is the full path, e.g. "Homelab/Proxmox".
+ Task CreateFolderAsync(string name, CancellationToken cancellationToken = default);
+
+ ///
+ /// Renames a folder to the given full path.
+ ///
+ ///
+ /// This renames one folder only. Because Bitwarden stores folders flat, a tree operation is
+ /// several of these — plan it with rather than calling
+ /// this directly, or descendants get left behind under the old name.
+ ///
+ Task RenameFolderAsync(
+ string id, string name, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a folder. Items inside it are not deleted; Bitwarden unfiles them.
+ ///
+ Task DeleteFolderAsync(string id, CancellationToken cancellationToken = default);
+}
+
+/// Lock state and identity of the vault behind an .
+public sealed record VaultStatus
+{
+ public required string Status { get; init; }
+
+ public string? UserEmail { get; init; }
+
+ public string? ServerUrl { get; init; }
+
+ public DateTimeOffset? LastSync { get; init; }
+
+ public bool IsUnlocked => string.Equals(Status, "unlocked", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/Application/Abstractions/IVaultSession.cs b/src/Application/Abstractions/IVaultSession.cs
new file mode 100644
index 0000000..4b86f49
--- /dev/null
+++ b/src/Application/Abstractions/IVaultSession.cs
@@ -0,0 +1,40 @@
+namespace BitwardenSharp.Application.Abstractions;
+
+/// The outcome of an unlock attempt.
+public sealed record UnlockResult
+{
+ public required bool Succeeded { get; init; }
+
+ /// Why it failed, safe to show a user. Null on success.
+ public string? Error { get; init; }
+
+ public static UnlockResult Success() => new() { Succeeded = true };
+
+ public static UnlockResult Failure(string error) => new() { Succeeded = false, Error = error };
+}
+
+///
+/// Controls the lock state of the vault.
+///
+///
+/// Separate from on purpose. Reading and merging need an unlocked
+/// vault but have no business unlocking one, and the CLI presentation never unlocks at all — it
+/// inherits a session from the environment. Only the desktop host takes a master password, so
+/// only it depends on this.
+///
+public interface IVaultSession
+{
+ Task GetStatusAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Unlocks the vault and retains the resulting session key for subsequent vault calls.
+ ///
+ ///
+ /// The password is used once and not retained. Implementations must not place it on a
+ /// command line — see the note on the bw adapter.
+ ///
+ Task UnlockAsync(string masterPassword, CancellationToken cancellationToken = default);
+
+ /// Discards the session key and locks the vault.
+ Task LockAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Application/ApplicationServiceExtensions.cs b/src/Application/ApplicationServiceExtensions.cs
new file mode 100644
index 0000000..92f82df
--- /dev/null
+++ b/src/Application/ApplicationServiceExtensions.cs
@@ -0,0 +1,18 @@
+using BitwardenSharp.Application.Duplicates;
+using BitwardenSharp.Application.Folders;
+using BitwardenSharp.Application.Merging;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BitwardenSharp.Application;
+
+/// Registers the vault operations. Requires an IVaultClient from Infrastructure.
+public static class ApplicationServiceExtensions
+{
+ public static IServiceCollection AddBitwardenSharpApplication(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ return services;
+ }
+}
diff --git a/src/Application/BitwardenSharp.Application.csproj b/src/Application/BitwardenSharp.Application.csproj
new file mode 100644
index 0000000..883cfcc
--- /dev/null
+++ b/src/Application/BitwardenSharp.Application.csproj
@@ -0,0 +1,13 @@
+
+
+ Vault operations for BitwardenSharp — duplicate detection, survivor selection and merge planning. Owns the IVaultClient port; holds no transport of its own.
+ bitwarden;vault;deduplication;password-manager
+
+
+
+
+
+
+
+
+
diff --git a/src/Application/Duplicates/DuplicateScanResult.cs b/src/Application/Duplicates/DuplicateScanResult.cs
new file mode 100644
index 0000000..2a74af8
--- /dev/null
+++ b/src/Application/Duplicates/DuplicateScanResult.cs
@@ -0,0 +1,23 @@
+using BitwardenSharp.Domain.Duplicates;
+
+namespace BitwardenSharp.Application.Duplicates;
+
+/// Everything one scan found.
+public sealed record DuplicateScanResult
+{
+ public required int TotalItems { get; init; }
+
+ public required int LoginCount { get; init; }
+
+ public required IReadOnlyList Groups { get; init; }
+
+ public IEnumerable Mergeable => Groups.Where(g => g.CanMerge);
+
+ public IEnumerable NeedingReview => Groups.Where(g => !g.CanMerge);
+
+ /// How many items would be deleted if every mergeable group were applied.
+ public int MergeableDeletions => Mergeable.Sum(g => g.Losers.Count());
+
+ public IReadOnlyDictionary CountByCategory =>
+ Groups.GroupBy(g => g.Category).ToDictionary(g => g.Key, g => g.Count());
+}
diff --git a/src/Application/Duplicates/DuplicateScanner.cs b/src/Application/Duplicates/DuplicateScanner.cs
new file mode 100644
index 0000000..a7637c4
--- /dev/null
+++ b/src/Application/Duplicates/DuplicateScanner.cs
@@ -0,0 +1,201 @@
+using BitwardenSharp.Domain.Duplicates;
+using BitwardenSharp.Domain.Uris;
+using BitwardenSharp.Domain.Vault;
+
+namespace BitwardenSharp.Application.Duplicates;
+
+///
+/// Finds sets of login items that describe the same account, and classifies each set by how
+/// safely it can be collapsed.
+///
+///
+///
+/// The ordering of the passes matters. Each pass claims the items it groups, so a later, weaker
+/// signal cannot re-group items an earlier, stronger one already explained.
+///
+///
+/// A shared password is not evidence of a duplicate. Password reuse is common enough that
+/// on a real vault a single password can cover hundreds of unrelated accounts, so "same
+/// credentials" only ever promotes a group that some other signal — same registrable target, same
+/// brand, same service family — has already established. An earlier version of this rule accepted
+/// a group when *any one* of its domains belonged to a known family, which swept unrelated sites
+/// together and proposed deleting live accounts. See the RelatedCredentials pass.
+///
+///
+public sealed class DuplicateScanner
+{
+ /// Groups the login items in , strongest signal first.
+ public DuplicateScanResult Scan(IReadOnlyList items)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+
+ var logins = items
+ .Where(i => i.Type == ItemType.Login && i.Login is not null)
+ .OrderBy(i => i.Id, StringComparer.Ordinal)
+ .ToList();
+
+ var targets = logins.ToDictionary(
+ i => i.Id,
+ i => i.Uris.Select(u => UriTarget.Parse(u.Uri)).OfType().Distinct().ToList());
+
+ var claimed = new HashSet(StringComparer.Ordinal);
+ var seen = new HashSet(StringComparer.Ordinal);
+ var groups = new List();
+ var counters = new Dictionary();
+
+ void Emit(DuplicateCategory category, string key, IReadOnlyList members, bool claim)
+ {
+ var signature = Signature(members);
+ if (members.Count < 2 || !seen.Add(signature)) return;
+
+ counters[category] = counters.GetValueOrDefault(category) + 1;
+ groups.Add(Build($"{Prefix(category)}-{counters[category]:D3}", category, key, members, targets));
+ if (claim) foreach (var m in members) claimed.Add(m.Id);
+ }
+
+ // ── Pass 1: same target, same username, same password ────────────────────────────────
+ // The strongest signal there is. Everything here is one account the vault recorded twice.
+ var byTargetUser = new Dictionary<(string Target, string User), List>();
+ foreach (var item in logins)
+ {
+ var user = item.Login!.NormalisedUsername;
+ if (user is null || item.Login.PasswordFingerprint is null) continue;
+ foreach (var target in targets[item.Id])
+ byTargetUser.GetOrAdd((target.Value, user)).Add(item);
+ }
+
+ var conflicts = new List<((string Target, string User) Key, List Members)>();
+ foreach (var (key, members) in byTargetUser.OrderBy(kv => kv.Key))
+ {
+ var distinct = members.DistinctBy(m => m.Id).ToList();
+ if (distinct.Count < 2) continue;
+
+ var byPassword = distinct.GroupBy(m => m.Login!.PasswordFingerprint!).ToList();
+ foreach (var sharing in byPassword.Where(g => g.Count() > 1))
+ Emit(DuplicateCategory.ExactDuplicate, $"{key.Target} · {key.User}", [.. sharing], claim: true);
+
+ // Same door, same name, different keys — one of them is stale. Held for pass 3 so
+ // that any exact duplicates inside it are recognised as such first.
+ if (byPassword.Count > 1) conflicts.Add((key, distinct));
+ }
+
+ // ── Pass 2: same credentials across related targets ──────────────────────────────────
+ var byCredential = new Dictionary<(string User, string Password), List>();
+ foreach (var item in logins)
+ {
+ var user = item.Login!.NormalisedUsername;
+ var password = item.Login.PasswordFingerprint;
+ if (user is null || password is null) continue;
+ byCredential.GetOrAdd((user, password)).Add(item);
+ }
+
+ foreach (var (key, members) in byCredential.OrderBy(kv => kv.Key))
+ {
+ if (members.Count < 2) continue;
+
+ var distinctTargets = members.SelectMany(m => targets[m.Id]).Distinct().ToList();
+ if (distinctTargets.Count < 2) continue;
+
+ var infrastructure = distinctTargets
+ .Where(t => t.Kind is UriTargetKind.IpAddress or UriTargetKind.Host)
+ .ToList();
+ var domains = distinctTargets.Where(t => t.Kind == UriTargetKind.Domain).ToList();
+
+ if (infrastructure.Count > 1)
+ {
+ // Distinct machines that happen to share a login. Merging would delete the
+ // inventory of every host but one.
+ Emit(DuplicateCategory.InfrastructureSharedCredential,
+ $"{key.User} · {infrastructure.Count} hosts", members, claim: false);
+ continue;
+ }
+
+ // Related only if EVERY domain agrees — one shared brand, or one shared family with
+ // no unrecognised brand among them. Anything else is reuse across unrelated sites.
+ var brands = domains.Select(d => d.Brand).Distinct().ToList();
+ var families = domains.Select(ServiceFamily.ForTarget).ToList();
+
+ var sameBrand = brands.Count == 1;
+ var sameFamily = domains.Count > 0
+ && families.All(f => f is not null)
+ && families.Distinct().Count() == 1;
+
+ if (sameBrand || sameFamily)
+ Emit(DuplicateCategory.RelatedDomain,
+ $"{key.User} · {string.Join(", ", distinctTargets.Select(t => t.Value).Order())}",
+ members, claim: true);
+ }
+
+ // ── Pass 3: same target and username, different passwords ────────────────────────────
+ foreach (var (key, members) in conflicts)
+ Emit(DuplicateCategory.CredentialConflict,
+ $"{key.Target} · {key.User}", members, claim: false);
+
+ // ── Pass 4: identical name, nothing else in common ───────────────────────────────────
+ foreach (var byName in logins
+ .Where(i => !string.IsNullOrWhiteSpace(i.Name))
+ .GroupBy(i => i.Name.Trim(), StringComparer.OrdinalIgnoreCase)
+ .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ var members = byName.ToList();
+ if (members.Count < 2 || members.Any(m => claimed.Contains(m.Id))) continue;
+ Emit(DuplicateCategory.SameName, byName.Key, members, claim: false);
+ }
+
+ return new DuplicateScanResult
+ {
+ TotalItems = items.Count,
+ LoginCount = logins.Count,
+ Groups = groups,
+ };
+ }
+
+ private static string Signature(IEnumerable members) =>
+ string.Join('|', members.Select(m => m.Id).Order(StringComparer.Ordinal));
+
+ private static string Prefix(DuplicateCategory category) => category switch
+ {
+ DuplicateCategory.ExactDuplicate => "EXACT",
+ DuplicateCategory.RelatedDomain => "RELATED",
+ DuplicateCategory.CredentialConflict => "CONFLICT",
+ DuplicateCategory.InfrastructureSharedCredential => "INFRA",
+ DuplicateCategory.SameName => "NAME",
+ _ => "GROUP",
+ };
+
+ private static DuplicateGroup Build(
+ string id,
+ DuplicateCategory category,
+ string key,
+ IReadOnlyList members,
+ IReadOnlyDictionary> targets)
+ {
+ // Richest survives: everything the others hold can be copied onto it, and an attachment
+ // cannot be moved between items at all. Newest revision breaks a tie.
+ var survivor = members
+ .OrderByDescending(m => m.Richness)
+ .ThenByDescending(m => m.RevisionDate ?? DateTimeOffset.MinValue)
+ .ThenBy(m => m.Id, StringComparer.Ordinal)
+ .First();
+
+ return new DuplicateGroup
+ {
+ Id = id,
+ Category = category,
+ Key = key,
+ Survivor = survivor,
+ Members = members,
+ Warnings = MergeWarnings.For(members, survivor),
+ };
+ }
+}
+
+internal static class DictionaryExtensions
+{
+ public static List GetOrAdd(
+ this Dictionary> source, TKey key) where TKey : notnull
+ {
+ if (source.TryGetValue(key, out var existing)) return existing;
+ return source[key] = [];
+ }
+}
diff --git a/src/Application/Duplicates/MergeWarnings.cs b/src/Application/Duplicates/MergeWarnings.cs
new file mode 100644
index 0000000..9f4c525
--- /dev/null
+++ b/src/Application/Duplicates/MergeWarnings.cs
@@ -0,0 +1,78 @@
+using BitwardenSharp.Domain.Duplicates;
+using BitwardenSharp.Domain.Vault;
+
+namespace BitwardenSharp.Application.Duplicates;
+
+/// Works out what a human needs to know before approving a group.
+internal static class MergeWarnings
+{
+ public static IReadOnlyList For(IReadOnlyList members, VaultItem survivor)
+ {
+ var warnings = new List();
+
+ // Blocking. `bw` exposes no way to move an attachment between items, so merging would
+ // delete the file along with its item. Refuse rather than lose it.
+ var withAttachments = members.Where(m => m.Attachments.Count > 0).ToList();
+ if (withAttachments.Count > 0)
+ {
+ var total = withAttachments.Sum(m => m.Attachments.Count);
+ warnings.Add(new MergeWarning(
+ "attachments",
+ $"{total} attachment(s) across {withAttachments.Count} item(s); the CLI cannot move "
+ + "attachments between items, so these must be handled by hand")
+ { IsBlocking = true });
+ }
+
+ // Blocking. Two different second factors for one account means one of them is wrong, and
+ // guessing costs the account.
+ var seeds = members
+ .Select(m => m.Login?.Totp)
+ .Where(t => !string.IsNullOrWhiteSpace(t))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ if (seeds.Count > 1)
+ {
+ warnings.Add(new MergeWarning(
+ "totp-conflict",
+ $"{seeds.Count} differing TOTP seeds in this group; resolve which is current before merging")
+ { IsBlocking = true });
+ }
+ else if (seeds.Count == 1 && string.IsNullOrWhiteSpace(survivor.Login?.Totp))
+ {
+ warnings.Add(new MergeWarning(
+ "totp-transfer",
+ "the TOTP seed is on an item being deleted and will be carried onto the survivor"));
+ }
+
+ var notes = members
+ .Select(m => m.Notes?.Trim())
+ .Where(n => !string.IsNullOrEmpty(n))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ if (notes.Count > 1)
+ warnings.Add(new MergeWarning("notes", $"{notes.Count} differing notes will be concatenated"));
+
+ var clashing = members
+ .SelectMany(m => m.Fields)
+ .Where(f => !string.IsNullOrWhiteSpace(f.Name))
+ .GroupBy(f => f.Name!, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Select(f => f.Value).Distinct(StringComparer.Ordinal).Count() > 1)
+ .Select(g => g.Key)
+ .ToList();
+ if (clashing.Count > 0)
+ warnings.Add(new MergeWarning(
+ "field-conflict",
+ $"custom field(s) with the same name but different values: {string.Join(", ", clashing)}; "
+ + "the survivor's value is kept"));
+
+ var folders = members
+ .Select(m => m.FolderId)
+ .Where(f => !string.IsNullOrWhiteSpace(f))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ if (folders.Count > 1)
+ warnings.Add(new MergeWarning("folder-span", $"members span {folders.Count} different folders"));
+
+ return warnings;
+ }
+}
diff --git a/src/Application/Folders/FolderService.cs b/src/Application/Folders/FolderService.cs
new file mode 100644
index 0000000..7d504ab
--- /dev/null
+++ b/src/Application/Folders/FolderService.cs
@@ -0,0 +1,137 @@
+using BitwardenSharp.Application.Abstractions;
+using BitwardenSharp.Domain.Vault;
+using Microsoft.Extensions.Logging;
+
+namespace BitwardenSharp.Application.Folders;
+
+/// The result of a folder operation, in terms the UI can show directly.
+public sealed record FolderOperationResult
+{
+ public required bool Succeeded { get; init; }
+ public string? Error { get; init; }
+
+ /// How many folders were renamed. A subtree move touches more than one.
+ public int FoldersChanged { get; init; }
+
+ /// How many items were moved between folders.
+ public int ItemsChanged { get; init; }
+
+ public static FolderOperationResult Failure(string error) => new() { Succeeded = false, Error = error };
+}
+
+///
+/// File-explorer operations over Bitwarden's flat folder list.
+///
+///
+/// Every method here plans the whole operation with before writing
+/// anything, so an invalid gesture — a name collision, a folder dropped into its own subtree —
+/// is refused with the vault untouched rather than half-applied.
+///
+public sealed class FolderService(IVaultClient vault, ILogger? logger = null)
+{
+ public async Task CreateAsync(
+ string? parentPath,
+ string leafName,
+ CancellationToken cancellationToken = default)
+ {
+ var folders = await vault.GetFoldersAsync(cancellationToken);
+ var plan = FolderPaths.PlanCreate(folders, parentPath, leafName);
+ if (!plan.IsValid) return FolderOperationResult.Failure(plan.Error!.Message);
+
+ var name = plan.Renames[0].NewName;
+ await vault.CreateFolderAsync(name, cancellationToken);
+ logger?.LogInformation("Created folder {Name}", name);
+
+ return new FolderOperationResult { Succeeded = true, FoldersChanged = 1 };
+ }
+
+ /// Renames a folder's own segment, carrying its whole subtree.
+ public async Task RenameAsync(
+ string folderId,
+ string newLeafName,
+ CancellationToken cancellationToken = default)
+ {
+ var folders = await vault.GetFoldersAsync(cancellationToken);
+ return await ApplyAsync(FolderPaths.PlanRename(folders, folderId, newLeafName), cancellationToken);
+ }
+
+ /// Moves a folder under a new parent, or to the root when the path is null.
+ public async Task MoveAsync(
+ string folderId,
+ string? newParentPath,
+ CancellationToken cancellationToken = default)
+ {
+ var folders = await vault.GetFoldersAsync(cancellationToken);
+ return await ApplyAsync(FolderPaths.PlanMove(folders, folderId, newParentPath), cancellationToken);
+ }
+
+ ///
+ /// Deletes a folder.
+ ///
+ ///
+ /// Bitwarden unfiles the items inside rather than deleting them, which is the behaviour we
+ /// want. Descendant folders are separate records and are not removed automatically —
+ /// deleting "Homelab" would strand "Homelab/Proxmox" as a root-level folder with a slash in
+ /// its name. deletes the subtree instead, deepest first.
+ ///
+ public async Task DeleteAsync(
+ string folderId,
+ bool includeDescendants = true,
+ CancellationToken cancellationToken = default)
+ {
+ var folders = await vault.GetFoldersAsync(cancellationToken);
+ var target = folders.FirstOrDefault(f => f.Id == folderId);
+ if (target is null) return FolderOperationResult.Failure("That folder no longer exists.");
+
+ var doomed = new List { target };
+ if (includeDescendants)
+ doomed.AddRange(folders.Where(f => FolderPaths.IsDescendantOf(f.Name, target.Name)));
+
+ // Deepest first, so the tree never passes through a state with a parentless child.
+ foreach (var folder in doomed.OrderByDescending(f => FolderPaths.Segments(f.Name).Count))
+ {
+ await vault.DeleteFolderAsync(folder.Id, cancellationToken);
+ logger?.LogInformation("Deleted folder {Name}", folder.Name);
+ }
+
+ return new FolderOperationResult { Succeeded = true, FoldersChanged = doomed.Count };
+ }
+
+ /// Moves items into a folder, or out of any folder when the id is null.
+ public async Task MoveItemsAsync(
+ IReadOnlyList itemIds,
+ string? targetFolderId,
+ CancellationToken cancellationToken = default)
+ {
+ var moved = 0;
+ foreach (var id in itemIds)
+ {
+ var item = await vault.GetItemAsync(id, cancellationToken);
+ if (item.FolderId == targetFolderId) continue;
+
+ await vault.UpdateItemAsync(item with { FolderId = targetFolderId }, cancellationToken);
+ moved++;
+ }
+
+ logger?.LogInformation("Moved {Count} item(s) to folder {FolderId}", moved, targetFolderId ?? "");
+ return new FolderOperationResult { Succeeded = true, ItemsChanged = moved };
+ }
+
+ private async Task ApplyAsync(
+ FolderPlan plan,
+ CancellationToken cancellationToken)
+ {
+ if (!plan.IsValid) return FolderOperationResult.Failure(plan.Error!.Message);
+ if (plan.Renames.Count == 0) return new FolderOperationResult { Succeeded = true };
+
+ // The plan orders descendants deepest-first; applying in that order means no two folders
+ // ever momentarily share a name.
+ foreach (var rename in plan.Renames)
+ {
+ await vault.RenameFolderAsync(rename.FolderId, rename.NewName, cancellationToken);
+ logger?.LogInformation("Renamed folder {Old} -> {New}", rename.OldName, rename.NewName);
+ }
+
+ return new FolderOperationResult { Succeeded = true, FoldersChanged = plan.Renames.Count };
+ }
+}
diff --git a/src/Application/Merging/MergeBuilder.cs b/src/Application/Merging/MergeBuilder.cs
new file mode 100644
index 0000000..9bab86e
--- /dev/null
+++ b/src/Application/Merging/MergeBuilder.cs
@@ -0,0 +1,171 @@
+using BitwardenSharp.Domain.Vault;
+
+namespace BitwardenSharp.Application.Merging;
+
+/// The merged item, and a human-readable account of what changed to produce it.
+public sealed record MergeResult(VaultItem Merged, IReadOnlyList Changes);
+
+///
+/// Folds the losers of a duplicate group onto its survivor. Purely additive: the survivor's own
+/// name, username, password and folder are never overwritten, so the worst case of a wrong
+/// grouping is an item carrying a URI it did not need — not a changed credential.
+///
+public static class MergeBuilder
+{
+ internal const string NoteSeparator = "\n\n--- merged ---\n";
+
+ ///
+ /// Builds the item a resolved describes.
+ ///
+ ///
+ ///
+ /// Unlike the additive overload, this one may replace the target's own values — that is the
+ /// point of the editor. The one replacement worth special handling is the password: when a
+ /// draft displaces one, the displaced value is pushed onto
+ /// rather than dropped.
+ ///
+ ///
+ /// That matters because picking the wrong side of a credential conflict is the single way this
+ /// tool can lose something irreplaceable. The losing item goes to the trash and is restorable
+ /// for 30 days; the displaced password stays on the surviving item indefinitely. Two
+ /// independent recovery paths for one irreversible decision.
+ ///
+ ///
+ public static MergeResult Build(MergeDraft draft)
+ {
+ ArgumentNullException.ThrowIfNull(draft);
+
+ var changes = new List();
+ var target = draft.TargetItem;
+
+ // A new item inherits the type from the group and nothing else; there is no prior state.
+ var basis = target ?? new VaultItem
+ {
+ Id = string.Empty,
+ Type = draft.Group.Survivor.Type,
+ Name = draft.Name.Value,
+ };
+
+ var history = basis.PasswordHistory.ToList();
+ if (target is not null
+ && !string.IsNullOrEmpty(target.Login?.Password)
+ && !string.Equals(target.Login.Password, draft.Password.Value, StringComparison.Ordinal))
+ {
+ history.Insert(0, new PasswordHistoryEntry
+ {
+ Password = target.Login.Password,
+ LastUsedDate = target.Login.PasswordRevisionDate ?? target.RevisionDate,
+ });
+ changes.Add("password replaced (previous value kept in password history)");
+ }
+
+ foreach (var (field, _, _) in draft.Overwrites.Where(o => o.Field != "Password"))
+ changes.Add($"{field} replaced");
+
+ var addedUris = draft.Uris.Count - basis.Uris.Count;
+ if (addedUris > 0) changes.Add($"+{addedUris} uri(s)");
+
+ var addedFields = draft.Fields.Count - basis.Fields.Count;
+ if (addedFields > 0) changes.Add($"+{addedFields} custom field(s)");
+
+ var login = basis.Login ?? new LoginDetails();
+ var merged = basis with
+ {
+ Name = draft.Name.Value,
+ FolderId = draft.FolderId.Value,
+ Notes = string.IsNullOrWhiteSpace(draft.Notes.Value) ? null : draft.Notes.Value,
+ Favorite = draft.Favorite.Value,
+ Fields = draft.Fields,
+ PasswordHistory = history,
+ Login = basis.Type == ItemType.Login || draft.Group.Survivor.Type == ItemType.Login
+ ? login with
+ {
+ Username = draft.Username.Value,
+ Password = draft.Password.Value,
+ Totp = draft.Totp.Value,
+ Uris = draft.Uris,
+ }
+ : basis.Login,
+ };
+
+ return new MergeResult(merged, changes);
+ }
+
+ public static MergeResult Build(VaultItem survivor, IReadOnlyList losers)
+ {
+ ArgumentNullException.ThrowIfNull(survivor);
+ ArgumentNullException.ThrowIfNull(losers);
+
+ var changes = new List();
+ var login = survivor.Login ?? new LoginDetails();
+
+ // ── URIs: union, keeping each entry's own match rule ──────────────────────────────────
+ var uris = login.Uris.ToList();
+ var seenUris = new HashSet(
+ uris.Select(u => u.Uri.Trim()), StringComparer.OrdinalIgnoreCase);
+ foreach (var uri in losers.SelectMany(l => l.Uris))
+ {
+ if (string.IsNullOrWhiteSpace(uri.Uri) || !seenUris.Add(uri.Uri.Trim())) continue;
+ uris.Add(uri);
+ changes.Add($"+uri {uri.Uri}");
+ }
+
+ // ── TOTP: adopt only when unambiguous ─────────────────────────────────────────────────
+ var totp = login.Totp;
+ if (string.IsNullOrWhiteSpace(totp))
+ {
+ var seeds = losers
+ .Select(l => l.Login?.Totp)
+ .Where(t => !string.IsNullOrWhiteSpace(t))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
+ // Two differing seeds is a blocking warning upstream; refusing again here keeps the
+ // builder correct on its own, without depending on the caller having checked.
+ if (seeds.Count == 1)
+ {
+ totp = seeds[0];
+ changes.Add("+totp (adopted from a deleted item)");
+ }
+ }
+
+ // ── Custom fields: add only names the survivor lacks ──────────────────────────────────
+ var fields = survivor.Fields.ToList();
+ var seenFields = new HashSet(
+ fields.Select(f => f.Name ?? string.Empty), StringComparer.OrdinalIgnoreCase);
+ foreach (var field in losers.SelectMany(l => l.Fields))
+ {
+ if (!seenFields.Add(field.Name ?? string.Empty)) continue;
+ fields.Add(field);
+ changes.Add($"+field {field.Name}");
+ }
+
+ // ── Notes: append anything not already present ────────────────────────────────────────
+ var notes = survivor.Notes?.Trim() ?? string.Empty;
+ foreach (var loser in losers)
+ {
+ var note = loser.Notes?.Trim();
+ if (string.IsNullOrEmpty(note) || notes.Contains(note, StringComparison.Ordinal)) continue;
+ notes = notes.Length == 0 ? note : notes + NoteSeparator + note;
+ changes.Add($"+notes from \"{loser.Name}\"");
+ }
+
+ // ── Folder: only ever fills a gap ─────────────────────────────────────────────────────
+ var folderId = survivor.FolderId;
+ if (string.IsNullOrWhiteSpace(folderId))
+ {
+ folderId = losers.Select(l => l.FolderId).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f));
+ if (!string.IsNullOrWhiteSpace(folderId)) changes.Add("+folder (adopted from a deleted item)");
+ }
+
+ var merged = survivor with
+ {
+ FolderId = folderId,
+ Notes = notes.Length == 0 ? null : notes,
+ Fields = fields,
+ Login = login with { Uris = uris, Totp = totp },
+ };
+
+ return new MergeResult(merged, changes);
+ }
+}
diff --git a/src/Application/Merging/MergeDraft.cs b/src/Application/Merging/MergeDraft.cs
new file mode 100644
index 0000000..0be3121
--- /dev/null
+++ b/src/Application/Merging/MergeDraft.cs
@@ -0,0 +1,196 @@
+using BitwardenSharp.Domain.Duplicates;
+using BitwardenSharp.Domain.Vault;
+
+namespace BitwardenSharp.Application.Merging;
+
+/// Where a resolved merge is written.
+public sealed record MergeTarget
+{
+ /// The id of the member being kept, or null to create a brand-new item.
+ public string? ItemId { get; init; }
+
+ public bool IsNewItem => ItemId is null;
+
+ public static MergeTarget Existing(string itemId) => new() { ItemId = itemId };
+
+ public static readonly MergeTarget NewItem = new();
+}
+
+/// Where a resolved value came from, so the UI can show provenance.
+public enum ValueOrigin
+{
+ /// Every member agreed; there was nothing to decide.
+ Unanimous,
+
+ /// Taken from one member.
+ Member,
+
+ /// Typed by hand — a value that exists in no member.
+ Edited,
+
+ /// Built from several members, e.g. unioned URIs or concatenated notes.
+ Combined,
+}
+
+/// A resolved scalar, with where it came from.
+public sealed record Resolved(T Value, ValueOrigin Origin, string? SourceItemId = null)
+{
+ public static Resolved Unanimous(T value) => new(value, ValueOrigin.Unanimous);
+
+ public static Resolved From(T value, string itemId) => new(value, ValueOrigin.Member, itemId);
+
+ public static Resolved Edited(T value) => new(value, ValueOrigin.Edited);
+
+ public static Resolved Combined(T value) => new(value, ValueOrigin.Combined);
+}
+
+///
+/// The editable result of a merge: what the surviving item will contain, and where it is written.
+///
+///
+///
+/// This is the model behind the three-pane editor, and it lives in Application rather than the
+/// view because it is the thing that decides what gets overwritten. It must be testable without a
+/// UI — this is the operation that can destroy data.
+///
+///
+/// Unlike 's additive default, a draft may replace the survivor's own
+/// values, including its password. See for how that is surfaced, and
+/// for how a displaced password is preserved.
+///
+///
+public sealed record MergeDraft
+{
+ public required DuplicateGroup Group { get; init; }
+
+ public required MergeTarget Target { get; init; }
+
+ public required Resolved Name { get; init; }
+ public Resolved Username { get; init; } = Resolved.Unanimous(null);
+ public Resolved Password { get; init; } = Resolved.Unanimous(null);
+ public Resolved Totp { get; init; } = Resolved.Unanimous(null);
+ public Resolved FolderId { get; init; } = Resolved.Unanimous(null);
+ public Resolved Notes { get; init; } = Resolved.Unanimous(null);
+ public Resolved Favorite { get; init; } = Resolved.Unanimous(false);
+
+ public IReadOnlyList Uris { get; init; } = [];
+ public IReadOnlyList Fields { get; init; } = [];
+
+ /// The member being kept, or null when the target is a new item.
+ public VaultItem? TargetItem =>
+ Target.IsNewItem ? null : Group.Members.FirstOrDefault(m => m.Id == Target.ItemId);
+
+ /// Members that will be deleted once the merge is applied.
+ public IEnumerable Doomed =>
+ Group.Members.Where(m => Target.IsNewItem || m.Id != Target.ItemId);
+
+ ///
+ /// Whether the merge may be resolved into a brand-new item.
+ ///
+ ///
+ /// It may not when any member holds an attachment: the CLI cannot move an attachment between
+ /// items, so creating a third item and deleting the sources would destroy the file. Merging
+ /// into the member that holds it is still fine.
+ ///
+ public bool CanTargetNewItem => Group.Members.All(m => m.Attachments.Count == 0);
+
+ public string? NewItemBlockedReason => CanTargetNewItem
+ ? null
+ : "One of these items has an attachment, and the Bitwarden CLI cannot move attachments "
+ + "between items. Merge into the item that holds it instead.";
+
+ ///
+ /// Values on the target that this draft would replace, as (field, before, after).
+ ///
+ ///
+ /// Empty for a new item — nothing exists to overwrite. This is what the confirmation step
+ /// shows: additions are cheap and reversible, replacements are the part worth reading.
+ ///
+ public IReadOnlyList<(string Field, string? Before, string? After)> Overwrites
+ {
+ get
+ {
+ var target = TargetItem;
+ if (target is null) return [];
+
+ var changes = new List<(string, string?, string?)>();
+
+ // Named 'label' rather than 'field': inside a property accessor, C# 14 treats
+ // 'field' as a contextual keyword for the backing field.
+ //
+ // The comparison is always on the real values and the masking is applied only to what
+ // is reported. Masking first would compare bullet strings, and two different secrets
+ // of the same length would then look identical — a replaced password reported as no
+ // change at all.
+ void Compare(string label, string? before, string? after, bool secret = false)
+ {
+ if (string.Equals(before ?? string.Empty, after ?? string.Empty, StringComparison.Ordinal))
+ return;
+ changes.Add(secret
+ ? (label, Mask(before), Mask(after))
+ : (label, before, after));
+ }
+
+ Compare("Name", target.Name, Name.Value);
+ Compare("Username", target.Login?.Username, Username.Value);
+ Compare("Password", target.Login?.Password, Password.Value, secret: true);
+ Compare("TOTP", target.Login?.Totp, Totp.Value, secret: true);
+ Compare("Notes", target.Notes, Notes.Value);
+
+ return changes;
+ }
+ }
+
+ /// Whether this draft changes the target's password, which is the risky case.
+ public bool ReplacesPassword =>
+ TargetItem is { Login: not null } target
+ && !string.IsNullOrEmpty(target.Login.Password)
+ && !string.Equals(target.Login.Password, Password.Value, StringComparison.Ordinal);
+
+ private static string? Mask(string? secret) =>
+ string.IsNullOrEmpty(secret) ? null : new string('•', Math.Min(secret.Length, 12));
+
+ ///
+ /// The draft the wizard opens with: today's additive merge, expressed as explicit decisions.
+ ///
+ ///
+ /// Deliberately identical in outcome to
+ /// so that "approve the default" in the fast path and "open the editor and change nothing"
+ /// produce the same item. Scalars come from the survivor; collections are unioned.
+ ///
+ public static MergeDraft Default(DuplicateGroup group)
+ {
+ ArgumentNullException.ThrowIfNull(group);
+
+ var survivor = group.Survivor;
+ var others = group.Losers.ToList();
+
+ var (merged, _) = MergeBuilder.Build(survivor, others);
+
+ return new MergeDraft
+ {
+ Group = group,
+ Target = MergeTarget.Existing(survivor.Id),
+ Name = Agreed(group, m => m.Name) ?? Resolved.From(merged.Name, survivor.Id),
+ Username = Agreed(group, m => m.Login?.Username)
+ ?? Resolved.From(merged.Login?.Username, survivor.Id),
+ Password = Agreed(group, m => m.Login?.Password)
+ ?? Resolved.From(merged.Login?.Password, survivor.Id),
+ Totp = Agreed(group, m => m.Login?.Totp)
+ ?? Resolved.Combined(merged.Login?.Totp),
+ FolderId = Agreed(group, m => m.FolderId)
+ ?? Resolved.From(merged.FolderId, survivor.Id),
+ Notes = Agreed(group, m => m.Notes) ?? Resolved.Combined(merged.Notes),
+ Favorite = Agreed(group, m => m.Favorite) ?? Resolved.From(merged.Favorite, survivor.Id),
+ Uris = merged.Uris,
+ Fields = merged.Fields,
+ };
+ }
+
+ /// A resolved value when every member already agrees, otherwise null.
+ private static Resolved? Agreed(DuplicateGroup group, Func select)
+ {
+ var values = group.Members.Select(select).Distinct().ToList();
+ return values.Count == 1 ? Resolved.Unanimous(values[0]) : null;
+ }
+}
diff --git a/src/Application/Merging/MergeExecutor.cs b/src/Application/Merging/MergeExecutor.cs
new file mode 100644
index 0000000..07c5b17
--- /dev/null
+++ b/src/Application/Merging/MergeExecutor.cs
@@ -0,0 +1,243 @@
+using BitwardenSharp.Application.Abstractions;
+using BitwardenSharp.Domain.Duplicates;
+using BitwardenSharp.Domain.Vault;
+using Microsoft.Extensions.Logging;
+
+namespace BitwardenSharp.Application.Merging;
+
+/// What happened to one group.
+public enum MergeStatus
+{
+ /// Survivor updated and every loser deleted.
+ Merged,
+
+ /// Refused before any write: the group is not mergeable.
+ Skipped,
+
+ ///
+ /// The survivor did not read back with the merged content, so no loser was deleted.
+ /// Nothing was lost.
+ ///
+ VerificationFailed,
+
+ /// The vault rejected a call. Any loser not yet deleted is untouched.
+ Failed,
+}
+
+public sealed record MergeOutcome
+{
+ public required string GroupId { get; init; }
+
+ public required MergeStatus Status { get; init; }
+
+ public IReadOnlyList Changes { get; init; } = [];
+
+ public IReadOnlyList DeletedItemIds { get; init; } = [];
+
+ public string? Message { get; init; }
+}
+
+///
+/// Applies merges against a vault.
+///
+///
+///
+/// The write order is the safety property. For each group the survivor is updated first, then
+/// read back and verified, and only then are the losers deleted. There is therefore no
+/// moment at which a URI, seed or note exists in neither item: if anything fails, it fails with
+/// the losers still present and the operation is simply re-runnable.
+///
+///
+/// Items are re-read from the vault immediately before merging rather than reused from the scan.
+/// A scan is a snapshot, and acting on a stale one could overwrite a change made in the meantime.
+///
+///
+public sealed class MergeExecutor(IVaultClient vault, ILogger? logger = null)
+{
+ ///
+ /// Applies a resolved draft — the editor's path, where the target may be any member of the
+ /// group or a brand-new item, and values may be replaced rather than only added.
+ ///
+ ///
+ /// The ordering guarantee is the same in all three cases: the surviving item is written and
+ /// read back before any source is deleted. Creating a new item adds one failure mode — the
+ /// create succeeds and a delete then fails, leaving three items rather than one. That is
+ /// deliberately the direction the bias runs: a leftover duplicate is an annoyance, a lost
+ /// item is not.
+ ///
+ public async Task ApplyAsync(
+ MergeDraft draft,
+ bool dryRun = true,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(draft);
+
+ if (draft.Target.IsNewItem && !draft.CanTargetNewItem)
+ return new MergeOutcome
+ {
+ GroupId = draft.Group.Id,
+ Status = MergeStatus.Skipped,
+ Message = draft.NewItemBlockedReason,
+ };
+
+ try
+ {
+ var (merged, changes) = MergeBuilder.Build(draft);
+ var doomed = draft.Doomed.ToList();
+
+ if (dryRun)
+ return new MergeOutcome
+ {
+ GroupId = draft.Group.Id, Status = MergeStatus.Merged, Changes = changes,
+ };
+
+ string survivingId;
+ if (draft.Target.IsNewItem)
+ {
+ var created = await vault.CreateItemAsync(merged, cancellationToken);
+ survivingId = created.Id;
+ changes = [.. changes, "created as a new item"];
+ }
+ else
+ {
+ // Re-read: the scan is a snapshot and the item may have changed since.
+ await vault.GetItemAsync(merged.Id, cancellationToken);
+ await vault.UpdateItemAsync(merged, cancellationToken);
+ survivingId = merged.Id;
+ }
+
+ var readBack = await vault.GetItemAsync(survivingId, cancellationToken);
+ if (!Verifies(readBack, merged))
+ {
+ logger?.LogError(
+ "Group {GroupId}: survivor {ItemId} did not verify; nothing was deleted",
+ draft.Group.Id, survivingId);
+ return new MergeOutcome
+ {
+ GroupId = draft.Group.Id,
+ Status = MergeStatus.VerificationFailed,
+ Changes = changes,
+ Message = draft.Target.IsNewItem
+ ? "the new item did not read back as written; the originals were left alone"
+ : "the survivor did not read back as written; nothing was deleted",
+ };
+ }
+
+ var deleted = new List();
+ foreach (var item in doomed)
+ {
+ await vault.DeleteItemAsync(item.Id, permanent: false, cancellationToken);
+ deleted.Add(item.Id);
+ logger?.LogInformation("Group {GroupId}: deleted {ItemId} to trash", draft.Group.Id, item.Id);
+ }
+
+ return new MergeOutcome
+ {
+ GroupId = draft.Group.Id,
+ Status = MergeStatus.Merged,
+ Changes = changes,
+ DeletedItemIds = deleted,
+ };
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger?.LogError(ex, "Group {GroupId}: merge failed", draft.Group.Id);
+ return new MergeOutcome
+ {
+ GroupId = draft.Group.Id, Status = MergeStatus.Failed, Message = ex.Message,
+ };
+ }
+ }
+
+ ///
+ /// Whether what the vault stored matches what we asked it to store, on the fields a merge can
+ /// change. Checked before any deletion, so a silent write failure costs nothing.
+ ///
+ private static bool Verifies(VaultItem stored, VaultItem intended)
+ {
+ var storedUris = stored.Uris.Select(u => u.Uri.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ return intended.Uris.Select(u => u.Uri.Trim()).All(storedUris.Contains)
+ && string.Equals(stored.Name, intended.Name, StringComparison.Ordinal)
+ && string.Equals(stored.Login?.Password, intended.Login?.Password, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Applies one group. performs every read and computes the merge
+ /// but issues no write.
+ ///
+ public async Task ApplyAsync(
+ DuplicateGroup group,
+ bool dryRun = true,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(group);
+
+ if (!group.CanMerge)
+ {
+ var blocking = group.Warnings.Where(w => w.IsBlocking).Select(w => w.Message);
+ return new MergeOutcome
+ {
+ GroupId = group.Id,
+ Status = MergeStatus.Skipped,
+ Message = group.Category.Disposition() == MergeDisposition.ReviewOnly
+ ? $"{group.Category} is review-only and is never merged automatically"
+ : string.Join("; ", blocking),
+ };
+ }
+
+ try
+ {
+ // Re-read: the scan is a snapshot and may be stale.
+ var survivor = await vault.GetItemAsync(group.Survivor.Id, cancellationToken);
+ var losers = new List();
+ foreach (var loser in group.Losers)
+ losers.Add(await vault.GetItemAsync(loser.Id, cancellationToken));
+
+ var (merged, changes) = MergeBuilder.Build(survivor, losers);
+
+ if (dryRun)
+ return new MergeOutcome { GroupId = group.Id, Status = MergeStatus.Merged, Changes = changes };
+
+ await vault.UpdateItemAsync(merged, cancellationToken);
+
+ // Verify before deleting anything.
+ var readBack = await vault.GetItemAsync(merged.Id, cancellationToken);
+ var stored = readBack.Uris.Select(u => u.Uri.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var expected = merged.Uris.Select(u => u.Uri.Trim());
+ if (!expected.All(stored.Contains))
+ {
+ logger?.LogError(
+ "Group {GroupId}: survivor {ItemId} did not verify after update; losers left in place",
+ group.Id, merged.Id);
+ return new MergeOutcome
+ {
+ GroupId = group.Id,
+ Status = MergeStatus.VerificationFailed,
+ Changes = changes,
+ Message = "survivor did not read back with the merged URIs; nothing was deleted",
+ };
+ }
+
+ var deleted = new List();
+ foreach (var loser in losers)
+ {
+ await vault.DeleteItemAsync(loser.Id, permanent: false, cancellationToken);
+ deleted.Add(loser.Id);
+ logger?.LogInformation("Group {GroupId}: deleted {ItemId} to trash", group.Id, loser.Id);
+ }
+
+ return new MergeOutcome
+ {
+ GroupId = group.Id,
+ Status = MergeStatus.Merged,
+ Changes = changes,
+ DeletedItemIds = deleted,
+ };
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger?.LogError(ex, "Group {GroupId}: merge failed", group.Id);
+ return new MergeOutcome { GroupId = group.Id, Status = MergeStatus.Failed, Message = ex.Message };
+ }
+ }
+}
diff --git a/src/Domain/BitwardenSharp.Domain.csproj b/src/Domain/BitwardenSharp.Domain.csproj
new file mode 100644
index 0000000..5eac3af
--- /dev/null
+++ b/src/Domain/BitwardenSharp.Domain.csproj
@@ -0,0 +1,6 @@
+
+
+ Vault model for BitwardenSharp — items, logins, URIs, folders and the duplicate-analysis value types. Depends on nothing.
+ bitwarden;vault;password-manager
+
+
diff --git a/src/Domain/Duplicates/DuplicateCategory.cs b/src/Domain/Duplicates/DuplicateCategory.cs
new file mode 100644
index 0000000..71728cf
--- /dev/null
+++ b/src/Domain/Duplicates/DuplicateCategory.cs
@@ -0,0 +1,61 @@
+namespace BitwardenSharp.Domain.Duplicates;
+
+/// Why a set of items was grouped, and therefore what may safely be done with it.
+public enum DuplicateCategory
+{
+ ///
+ /// Same registrable target, same username, same password. One account recorded more than
+ /// once — almost always a browser importing each subdomain it saw as its own entry.
+ ///
+ ExactDuplicate,
+
+ ///
+ /// Same username and password across different but genuinely related domains — the same
+ /// brand under two TLDs, or two front doors onto one .
+ /// Merging keeps every URI so autofill still fires on all of them.
+ ///
+ RelatedDomain,
+
+ ///
+ /// Same target and username but the passwords differ. One is stale; which one cannot be
+ /// determined from the vault alone. Never merged automatically.
+ ///
+ CredentialConflict,
+
+ ///
+ /// One credential reused across several distinct hosts or IPs. These are separate machines
+ /// that happen to share a login, not duplicates — merging them would delete the inventory.
+ /// Reported so the sprawl is visible, never merged.
+ ///
+ InfrastructureSharedCredential,
+
+ ///
+ /// Identical item name but the credentials differ. Too weak to act on alone; surfaced for a
+ /// human to look at.
+ ///
+ SameName,
+}
+
+/// What the tool is willing to do with a group without being told twice.
+public enum MergeDisposition
+{
+ /// Safe to merge once the operator has approved the group.
+ Mergeable,
+
+ /// Reported only. Requires a human decision that the vault data cannot supply.
+ ReviewOnly,
+}
+
+public static class DuplicateCategoryExtensions
+{
+ ///
+ /// Whether a category may ever be merged. Kept beside the enum rather than decided at the
+ /// call site so no future code path can quietly treat a conflict as mergeable.
+ ///
+ public static MergeDisposition Disposition(this DuplicateCategory category) => category switch
+ {
+ DuplicateCategory.ExactDuplicate => MergeDisposition.Mergeable,
+ DuplicateCategory.RelatedDomain => MergeDisposition.Mergeable,
+ _ => MergeDisposition.ReviewOnly,
+ };
+}
diff --git a/src/Domain/Duplicates/DuplicateGroup.cs b/src/Domain/Duplicates/DuplicateGroup.cs
new file mode 100644
index 0000000..bca3556
--- /dev/null
+++ b/src/Domain/Duplicates/DuplicateGroup.cs
@@ -0,0 +1,48 @@
+using BitwardenSharp.Domain.Vault;
+
+namespace BitwardenSharp.Domain.Duplicates;
+
+/// Something about a group that a human should read before approving it.
+public sealed record MergeWarning(string Code, string Message)
+{
+ ///
+ /// A warning that makes the group unmergeable outright, rather than merely worth reading.
+ ///
+ public bool IsBlocking { get; init; }
+}
+
+///
+/// A set of items believed to describe one account, with the merge already worked out: which
+/// item survives, which are deleted, and what has to be carried across first.
+///
+public sealed record DuplicateGroup
+{
+ /// Stable within one scan, e.g. EXACT-007. Used to approve groups by name.
+ public required string Id { get; init; }
+
+ public required DuplicateCategory Category { get; init; }
+
+ /// What the members had in common — the grouping key, for display.
+ public required string Key { get; init; }
+
+ /// The item that will be kept and updated. Always a member of .
+ public required VaultItem Survivor { get; init; }
+
+ public required IReadOnlyList Members { get; init; }
+
+ public IReadOnlyList Warnings { get; init; } = [];
+
+ /// The items that would be deleted, in the order they would be deleted.
+ public IEnumerable Losers => Members.Where(m => m.Id != Survivor.Id);
+
+ ///
+ /// Whether this group can be applied. A mergeable category still refuses when a blocking
+ /// warning is present — an attachment on a loser, for instance, which the CLI cannot move.
+ ///
+ public bool CanMerge =>
+ Category.Disposition() == MergeDisposition.Mergeable
+ && !Warnings.Any(w => w.IsBlocking);
+
+ public override string ToString() =>
+ $"{Id} [{Category}] {Key} — keep {Survivor.Name}, drop {Losers.Count()}";
+}
diff --git a/src/Domain/Uris/PublicSuffix.cs b/src/Domain/Uris/PublicSuffix.cs
new file mode 100644
index 0000000..b030eb8
--- /dev/null
+++ b/src/Domain/Uris/PublicSuffix.cs
@@ -0,0 +1,42 @@
+namespace BitwardenSharp.Domain.Uris;
+
+///
+/// The multi-label public suffixes needed to find the registrable domain (eTLD+1).
+///
+///
+///
+/// This is a curated subset, not the full Public Suffix List. The full list is ~10k entries that
+/// change monthly and would need a fetched, versioned data file; the failure mode of a missing
+/// entry here is narrow and safe — foo.co.zz would reduce to co.zz instead of
+/// foo.co.zz, which can only make two items look MORE alike, and every merge is reviewed
+/// before it is applied.
+///
+///
+/// Weighted towards where the vault actually lives: New Zealand, the UK, Germany and Australia.
+/// Add entries as they come up rather than importing the world.
+///
+///
+public static class PublicSuffix
+{
+ private static readonly HashSet MultiLabel = new(StringComparer.OrdinalIgnoreCase)
+ {
+ // New Zealand
+ "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz", "school.nz", "geek.nz", "kiwi.nz",
+ // United Kingdom
+ "co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "net.uk", "plc.uk", "ltd.uk",
+ // Australia
+ "com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au",
+ // Japan
+ "co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
+ // Rest of the world, as encountered
+ "com.br", "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
+ "co.za", "org.za", "co.in", "net.in", "org.in", "com.mx", "com.ar", "com.co",
+ "co.kr", "or.kr", "com.sg", "com.hk", "com.tw", "co.il", "com.tr", "co.id",
+ "com.my", "co.th", "com.ph", "com.vn", "com.pk", "com.eg", "com.sa", "com.ua",
+ "com.pl", "com.ru", "com.es", "co.ke", "com.ng", "com.pe", "com.ve", "com.uy",
+ "co.at", "co.hu", "com.de", "com.ee", "com.hr", "com.gr", "com.cy", "com.mt",
+ };
+
+ /// True when the final two labels of a host form a known multi-label suffix.
+ public static bool IsMultiLabelSuffix(string lastTwoLabels) => MultiLabel.Contains(lastTwoLabels);
+}
diff --git a/src/Domain/Uris/ServiceFamily.cs b/src/Domain/Uris/ServiceFamily.cs
new file mode 100644
index 0000000..0cbdf3e
--- /dev/null
+++ b/src/Domain/Uris/ServiceFamily.cs
@@ -0,0 +1,48 @@
+namespace BitwardenSharp.Domain.Uris;
+
+///
+/// Groups brands that are one account behind different front doors — gmail.com and youtube.com
+/// are one Google login, live.com and outlook.com are one Microsoft login.
+///
+///
+/// Membership here is a claim that a single set of credentials genuinely signs into all of them.
+/// It is deliberately conservative: a wrong entry causes two real, separate accounts to be
+/// proposed for merge, and the loser is deleted. When in doubt, leave a brand out — the cost of
+/// omission is one duplicate that survives, the cost of a false entry is a lost account.
+///
+public static class ServiceFamily
+{
+ private static readonly Dictionary BrandToFamily = BuildIndex(new()
+ {
+ ["google"] = ["google", "gmail", "googlemail", "youtube", "googleapis", "blogger", "firebase"],
+ ["microsoft"] = ["microsoft", "live", "outlook", "hotmail", "office", "office365", "msn",
+ "azure", "xbox", "skype", "sharepoint", "microsoftonline", "onedrive"],
+ ["amazon"] = ["amazon", "audible", "primevideo", "kindle"],
+ ["apple"] = ["apple", "icloud", "itunes"],
+ ["meta"] = ["facebook", "instagram", "whatsapp", "messenger", "meta", "oculus"],
+ ["atlassian"] = ["atlassian", "jira", "confluence", "bitbucket", "trello"],
+ ["valve"] = ["steampowered", "steamcommunity", "valvesoftware", "steam"],
+ ["ubisoft"] = ["ubisoft", "ubi", "uplay"],
+ ["ea"] = ["origin", "eaplay"],
+ ["adobe"] = ["adobe", "behance"],
+ ["sony"] = ["sony", "playstation", "sonyentertainmentnetwork"],
+ ["proton"] = ["proton", "protonvpn", "protonmail"],
+ ["x"] = ["twitter"],
+ });
+
+ private static Dictionary BuildIndex(Dictionary families)
+ {
+ var index = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (family, brands) in families)
+ foreach (var brand in brands)
+ index[brand] = family;
+ return index;
+ }
+
+ /// The family a brand belongs to, or null when it is not part of a known one.
+ public static string? ForBrand(string? brand) =>
+ brand is not null && BrandToFamily.TryGetValue(brand, out var family) ? family : null;
+
+ /// The family a target belongs to, or null for IPs, hosts, apps and unknown brands.
+ public static string? ForTarget(UriTarget target) => ForBrand(target.Brand);
+}
diff --git a/src/Domain/Uris/UriTarget.cs b/src/Domain/Uris/UriTarget.cs
new file mode 100644
index 0000000..927fa9e
--- /dev/null
+++ b/src/Domain/Uris/UriTarget.cs
@@ -0,0 +1,90 @@
+using System.Net;
+
+namespace BitwardenSharp.Domain.Uris;
+
+/// What a stored URI actually points at.
+public enum UriTargetKind
+{
+ /// A registrable internet domain, e.g. digikey.co.nz.
+ Domain,
+
+ /// A literal IP address. Almost always a homelab box.
+ IpAddress,
+
+ /// A dotless hostname — localhost, synology, an mDNS name.
+ Host,
+
+ /// A native app identifier from an androidapp:// or iosapp:// URI.
+ App,
+}
+
+///
+/// A stored URI reduced to the thing worth comparing: its registrable domain, IP, bare host or
+/// app id. Two logins are candidates for the same account only if their targets agree.
+///
+public sealed record UriTarget(UriTargetKind Kind, string Value)
+{
+ private static readonly string[] Schemes =
+ [
+ "http://", "https://", "ftp://", "ssh://", "sftp://", "androidapp://", "android://",
+ "iosapp://", "otpauth://", "chrome://", "moz-extension://", "file://",
+ ];
+
+ ///
+ /// Reduces a raw stored URI to its comparable target, or null when there is nothing to compare.
+ ///
+ public static UriTarget? Parse(string? uri)
+ {
+ if (string.IsNullOrWhiteSpace(uri)) return null;
+
+ var value = uri.Trim();
+
+ // Native app identifiers are their own namespace: "com.google.android.gm" is not a domain
+ // and must never be folded together with google.com by the domain rules below.
+ foreach (var appScheme in (string[])["androidapp://", "android://", "iosapp://"])
+ {
+ if (value.StartsWith(appScheme, StringComparison.OrdinalIgnoreCase))
+ {
+ var app = value[appScheme.Length..].Trim('/');
+ return app.Length == 0 ? null : new UriTarget(UriTargetKind.App, app.ToLowerInvariant());
+ }
+ }
+
+ foreach (var scheme in Schemes)
+ {
+ if (value.StartsWith(scheme, StringComparison.OrdinalIgnoreCase))
+ {
+ value = value[scheme.Length..];
+ break;
+ }
+ }
+
+ // Strip path, query and fragment.
+ value = value.Split('/')[0].Split('?')[0].Split('#')[0];
+
+ // Strip any userinfo, then the port.
+ var at = value.LastIndexOf('@');
+ if (at >= 0) value = value[(at + 1)..];
+ value = value.Split(':')[0].Trim().TrimEnd('.').ToLowerInvariant();
+
+ if (value.Length == 0) return null;
+
+ if (IPAddress.TryParse(value, out _)) return new UriTarget(UriTargetKind.IpAddress, value);
+
+ if (!value.Contains('.')) return new UriTarget(UriTargetKind.Host, value);
+
+ var labels = value.Split('.');
+ if (labels.Length >= 3 && PublicSuffix.IsMultiLabelSuffix($"{labels[^2]}.{labels[^1]}"))
+ return new UriTarget(UriTargetKind.Domain, string.Join('.', labels[^3..]));
+
+ return new UriTarget(UriTargetKind.Domain, string.Join('.', labels[^2..]));
+ }
+
+ ///
+ /// The leading label of a registrable domain — digikey.co.nz gives digikey.
+ /// Null for anything that is not a domain, so IPs never compare as brands.
+ ///
+ public string? Brand => Kind == UriTargetKind.Domain ? Value.Split('.')[0] : null;
+
+ public override string ToString() => $"{Value} ({Kind})";
+}
diff --git a/src/Domain/Vault/CustomField.cs b/src/Domain/Vault/CustomField.cs
new file mode 100644
index 0000000..0a1f0c6
--- /dev/null
+++ b/src/Domain/Vault/CustomField.cs
@@ -0,0 +1,19 @@
+namespace BitwardenSharp.Domain.Vault;
+
+/// A user-defined field on an item.
+public sealed record CustomField
+{
+ public string? Name { get; init; }
+
+ public string? Value { get; init; }
+
+ public FieldType Type { get; init; }
+
+ public string? LinkedId { get; init; }
+
+ ///
+ /// Redacts : a field may be , and this is
+ /// exactly where API keys and recovery codes end up.
+ ///
+ public override string ToString() => $"CustomField {{ Name = {Name}, Type = {Type} }}";
+}
diff --git a/src/Domain/Vault/FieldType.cs b/src/Domain/Vault/FieldType.cs
new file mode 100644
index 0000000..983ba0a
--- /dev/null
+++ b/src/Domain/Vault/FieldType.cs
@@ -0,0 +1,10 @@
+namespace BitwardenSharp.Domain.Vault;
+
+/// Custom-field kind, using the CLI's own numeric values.
+public enum FieldType
+{
+ Text = 0,
+ Hidden = 1,
+ Boolean = 2,
+ Linked = 3,
+}
diff --git a/src/Domain/Vault/FolderPaths.cs b/src/Domain/Vault/FolderPaths.cs
new file mode 100644
index 0000000..6a2939d
--- /dev/null
+++ b/src/Domain/Vault/FolderPaths.cs
@@ -0,0 +1,166 @@
+namespace BitwardenSharp.Domain.Vault;
+
+/// One folder that has to be renamed to carry out a tree operation.
+public sealed record FolderRename(string FolderId, string OldName, string NewName);
+
+/// Why a folder operation was refused, in words a user can act on.
+public sealed record FolderOperationError(string Message);
+
+/// A planned tree operation: the renames to apply, or the reason it cannot be done.
+public sealed record FolderPlan
+{
+ public IReadOnlyList Renames { get; init; } = [];
+
+ public FolderOperationError? Error { get; init; }
+
+ public bool IsValid => Error is null;
+
+ public static FolderPlan Invalid(string message) => new() { Error = new FolderOperationError(message) };
+
+ public static FolderPlan Of(IReadOnlyList renames) => new() { Renames = renames };
+}
+
+///
+/// Tree operations over Bitwarden's flat folder list.
+///
+///
+///
+/// Bitwarden has no folder hierarchy. "Homelab/Proxmox" is a single folder whose name
+/// contains a slash; it is not a child of "Homelab", and "Homelab" need not even exist. Clients
+/// render the implied tree, but the storage is flat.
+///
+///
+/// The consequence is that every tree operation is a bulk rename. Renaming "Homelab" to "Lab"
+/// leaves "Homelab/Proxmox" untouched unless it is renamed too — the UI would show the folder
+/// moving and its contents staying behind. Everything here therefore plans the full set of
+/// renames, including descendants, before anything is written.
+///
+///
+public static class FolderPaths
+{
+ public const char Separator = '/';
+
+ /// Trims each segment and drops empties, so " A / / B " becomes "A/B".
+ public static string Normalise(string name) =>
+ string.Join(Separator, Segments(name));
+
+ public static IReadOnlyList Segments(string? name) =>
+ (name ?? string.Empty)
+ .Split(Separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ /// The parent path of "A/B/C" is "A/B"; a root folder has none.
+ public static string? Parent(string name)
+ {
+ var segments = Segments(name);
+ return segments.Count <= 1 ? null : string.Join(Separator, segments.Take(segments.Count - 1));
+ }
+
+ /// The last segment: "A/B/C" gives "C".
+ public static string Leaf(string name) => Segments(name).LastOrDefault() ?? string.Empty;
+
+ /// Whether sits underneath .
+ ///
+ /// Compares whole segments, so "Homelab2" is not treated as a child of "Homelab" the way a
+ /// naive StartsWith would have it.
+ ///
+ public static bool IsDescendantOf(string candidate, string ancestor)
+ {
+ var a = Segments(ancestor);
+ var c = Segments(candidate);
+ return c.Count > a.Count
+ && a.Select((segment, i) => string.Equals(segment, c[i], StringComparison.OrdinalIgnoreCase))
+ .All(match => match);
+ }
+
+ private static string Join(string? parent, string leaf) =>
+ string.IsNullOrEmpty(parent) ? leaf : $"{parent}{Separator}{leaf}";
+
+ /// Rebases a path from one ancestor onto another, keeping the part below it.
+ private static string Rebase(string path, string oldRoot, string newRoot) =>
+ Join(newRoot, string.Join(Separator, Segments(path).Skip(Segments(oldRoot).Count)));
+
+ ///
+ /// Plans renaming a folder's own last segment, carrying every descendant with it.
+ ///
+ public static FolderPlan PlanRename(
+ IReadOnlyList folders,
+ string folderId,
+ string newLeafName)
+ {
+ var target = folders.FirstOrDefault(f => f.Id == folderId);
+ if (target is null) return FolderPlan.Invalid("That folder no longer exists.");
+
+ var leaf = Normalise(newLeafName);
+ if (leaf.Length == 0) return FolderPlan.Invalid("A folder needs a name.");
+ if (leaf.Contains(Separator))
+ return FolderPlan.Invalid(
+ $"A name cannot contain '{Separator}'. Move the folder instead to change where it sits.");
+
+ var newPath = Join(Parent(target.Name), leaf);
+ return PlanRebase(folders, target, newPath);
+ }
+
+ ///
+ /// Plans moving a folder under a new parent, or to the root when
+ /// is null.
+ ///
+ public static FolderPlan PlanMove(
+ IReadOnlyList folders,
+ string folderId,
+ string? newParentPath)
+ {
+ var target = folders.FirstOrDefault(f => f.Id == folderId);
+ if (target is null) return FolderPlan.Invalid("That folder no longer exists.");
+
+ var parent = newParentPath is null ? null : Normalise(newParentPath);
+
+ // Moving a folder inside itself would orphan the whole subtree.
+ if (parent is not null
+ && (string.Equals(parent, target.Name, StringComparison.OrdinalIgnoreCase)
+ || IsDescendantOf(parent, target.Name)))
+ return FolderPlan.Invalid("A folder cannot be moved inside itself.");
+
+ var newPath = Join(parent, Leaf(target.Name));
+ return PlanRebase(folders, target, newPath);
+ }
+
+ private static FolderPlan PlanRebase(
+ IReadOnlyList folders,
+ VaultFolder target,
+ string newPath)
+ {
+ if (string.Equals(newPath, target.Name, StringComparison.Ordinal)) return FolderPlan.Of([]);
+
+ var clash = folders.FirstOrDefault(f =>
+ f.Id != target.Id && string.Equals(f.Name, newPath, StringComparison.OrdinalIgnoreCase));
+ if (clash is not null) return FolderPlan.Invalid($"A folder called \"{newPath}\" already exists.");
+
+ var renames = new List { new(target.Id, target.Name, newPath) };
+
+ // Descendants are independent folders whose names merely start with the old path. They
+ // are renamed deepest-first so no intermediate state has two folders sharing a name.
+ renames.AddRange(folders
+ .Where(f => IsDescendantOf(f.Name, target.Name))
+ .OrderByDescending(f => Segments(f.Name).Count)
+ .Select(f => new FolderRename(f.Id, f.Name, Rebase(f.Name, target.Name, newPath))));
+
+ return FolderPlan.Of(renames);
+ }
+
+ /// Validates a name for a new folder at .
+ public static FolderPlan PlanCreate(
+ IReadOnlyList folders,
+ string? parentPath,
+ string leafName)
+ {
+ var leaf = Normalise(leafName);
+ if (leaf.Length == 0) return FolderPlan.Invalid("A folder needs a name.");
+
+ var path = Join(parentPath is null ? null : Normalise(parentPath), leaf);
+ if (folders.Any(f => string.Equals(f.Name, path, StringComparison.OrdinalIgnoreCase)))
+ return FolderPlan.Invalid($"A folder called \"{path}\" already exists.");
+
+ // Carries the intended full name as a rename with no id for the caller to create.
+ return FolderPlan.Of([new FolderRename(string.Empty, string.Empty, path)]);
+ }
+}
diff --git a/src/Domain/Vault/ItemAttachment.cs b/src/Domain/Vault/ItemAttachment.cs
new file mode 100644
index 0000000..a611cda
--- /dev/null
+++ b/src/Domain/Vault/ItemAttachment.cs
@@ -0,0 +1,15 @@
+namespace BitwardenSharp.Domain.Vault;
+
+///
+/// A file attached to an item. Present for detection only: the Bitwarden CLI offers no way to
+/// move an attachment between items, so any merge involving one has to be refused rather than
+/// silently dropping the file.
+///
+public sealed record ItemAttachment
+{
+ public required string Id { get; init; }
+
+ public string? FileName { get; init; }
+
+ public long? Size { get; init; }
+}
diff --git a/src/Domain/Vault/ItemDetails.cs b/src/Domain/Vault/ItemDetails.cs
new file mode 100644
index 0000000..8061e96
--- /dev/null
+++ b/src/Domain/Vault/ItemDetails.cs
@@ -0,0 +1,120 @@
+namespace BitwardenSharp.Domain.Vault;
+
+/// Payment card details. Every field here is sensitive.
+public sealed record CardDetails
+{
+ public string? CardholderName { get; init; }
+ public string? Brand { get; init; }
+ public string? Number { get; init; }
+ public string? ExpMonth { get; init; }
+ public string? ExpYear { get; init; }
+
+ /// CVV/CVC.
+ public string? Code { get; init; }
+
+ /// Last four digits, for display without revealing the card.
+ public string? LastFour =>
+ Number is { Length: >= 4 } number ? number[^4..] : null;
+
+ public string? Expiry => ExpMonth is null && ExpYear is null
+ ? null
+ : $"{ExpMonth?.PadLeft(2, '0') ?? "??"}/{ExpYear ?? "????"}";
+
+ /// Redacts the number and CVV; the generated record ToString would print both.
+ public override string ToString() =>
+ $"CardDetails {{ Brand = {Brand}, Number = {(Number is null ? "" : $"••••{LastFour}")}, "
+ + $"Code = {(Code is null ? "" : "")} }}";
+}
+
+///
+/// Personal identity details. Bitwarden omits null members from its JSON, so most of these are
+/// absent on any given item — the full set is modelled so a round-trip never drops one.
+///
+public sealed record IdentityDetails
+{
+ public string? Title { get; init; }
+ public string? FirstName { get; init; }
+ public string? MiddleName { get; init; }
+ public string? LastName { get; init; }
+ public string? Address1 { get; init; }
+ public string? Address2 { get; init; }
+ public string? Address3 { get; init; }
+ public string? City { get; init; }
+ public string? State { get; init; }
+ public string? PostalCode { get; init; }
+ public string? Country { get; init; }
+ public string? Company { get; init; }
+ public string? Email { get; init; }
+ public string? Phone { get; init; }
+ public string? Username { get; init; }
+
+ /// Social security or national insurance number.
+ public string? Ssn { get; init; }
+
+ public string? PassportNumber { get; init; }
+ public string? LicenseNumber { get; init; }
+
+ public string? FullName =>
+ string.Join(' ', new[] { Title, FirstName, MiddleName, LastName }
+ .Where(p => !string.IsNullOrWhiteSpace(p))) is { Length: > 0 } name
+ ? name
+ : null;
+
+ public string? Address =>
+ string.Join(", ", new[] { Address1, Address2, Address3, City, State, PostalCode, Country }
+ .Where(p => !string.IsNullOrWhiteSpace(p))) is { Length: > 0 } address
+ ? address
+ : null;
+
+ /// Redacts the government identifiers.
+ public override string ToString() =>
+ $"IdentityDetails {{ Name = {FullName}, Ssn = {(Ssn is null ? "" : "")}, "
+ + $"PassportNumber = {(PassportNumber is null ? "" : "")} }}";
+}
+
+/// Secure-note payload. Bitwarden only defines one kind; the text lives in the item's notes.
+public sealed record SecureNoteDetails
+{
+ public SecureNoteType Type { get; init; } = SecureNoteType.Generic;
+}
+
+public enum SecureNoteType
+{
+ Generic = 0,
+}
+
+/// An SSH key pair held in the vault.
+public sealed record SshKeyDetails
+{
+ public string? PrivateKey { get; init; }
+ public string? PublicKey { get; init; }
+ public string? KeyFingerprint { get; init; }
+
+ /// The algorithm, read off the public key ("ssh-ed25519 AAAA…" gives "ssh-ed25519").
+ public string? Algorithm => PublicKey?.Split(' ', 2).FirstOrDefault();
+
+ /// Redacts the private key.
+ public override string ToString() =>
+ $"SshKeyDetails {{ Algorithm = {Algorithm}, Fingerprint = {KeyFingerprint}, "
+ + $"PrivateKey = {(PrivateKey is null ? "" : "")} }}";
+}
+
+/// A password this item used previously, kept by Bitwarden when one is changed.
+public sealed record PasswordHistoryEntry
+{
+ public string? Password { get; init; }
+ public DateTimeOffset? LastUsedDate { get; init; }
+
+ /// Redacts the password — the whole point of this record is that it holds an old one.
+ public override string ToString() =>
+ $"PasswordHistoryEntry {{ LastUsedDate = {LastUsedDate}, Password = }}";
+}
+
+///
+/// Whether Bitwarden asks for the master password again before revealing this item.
+///
+public enum RepromptType
+{
+ None = 0,
+ MasterPassword = 1,
+}
diff --git a/src/Domain/Vault/ItemType.cs b/src/Domain/Vault/ItemType.cs
new file mode 100644
index 0000000..30159de
--- /dev/null
+++ b/src/Domain/Vault/ItemType.cs
@@ -0,0 +1,11 @@
+namespace BitwardenSharp.Domain.Vault;
+
+/// Bitwarden's item discriminator, using the CLI's own numeric values.
+public enum ItemType
+{
+ Login = 1,
+ SecureNote = 2,
+ Card = 3,
+ Identity = 4,
+ SshKey = 5,
+}
diff --git a/src/Domain/Vault/LoginDetails.cs b/src/Domain/Vault/LoginDetails.cs
new file mode 100644
index 0000000..2f0f2a6
--- /dev/null
+++ b/src/Domain/Vault/LoginDetails.cs
@@ -0,0 +1,55 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace BitwardenSharp.Domain.Vault;
+
+/// The credential half of a item.
+public sealed record LoginDetails
+{
+ public string? Username { get; init; }
+
+ public string? Password { get; init; }
+
+ /// TOTP seed or otpauth:// URI. Treated as a secret.
+ public string? Totp { get; init; }
+
+ public IReadOnlyList Uris { get; init; } = [];
+
+ public DateTimeOffset? PasswordRevisionDate { get; init; }
+
+ ///
+ /// Username lowered and trimmed, for comparison. Bitwarden preserves whatever case the user
+ /// typed, but "Chrison" and "chrison" on the same site are the same account.
+ ///
+ public string? NormalisedUsername =>
+ string.IsNullOrWhiteSpace(Username) ? null : Username.Trim().ToLowerInvariant();
+
+ ///
+ /// A stable, non-reversible fingerprint of the password, for equality comparison and for
+ /// display in reports. Two items share a password if and only if these match.
+ ///
+ ///
+ /// SHA-256 truncated to 12 hex characters. This exists so duplicate analysis can say "same
+ /// password" in a report a human reads, without that report becoming a plaintext password
+ /// dump. It is a comparison aid, never a credential — anything storing or transmitting the
+ /// actual secret must use directly and treat it accordingly.
+ ///
+ public string? PasswordFingerprint
+ {
+ get
+ {
+ if (string.IsNullOrEmpty(Password)) return null;
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(Password));
+ return Convert.ToHexStringLower(hash)[..12];
+ }
+ }
+
+ ///
+ /// Redacts every secret. The compiler-generated record ToString would print
+ /// and in full, so any log line, exception message
+ /// or debugger string that touched a login would leak it.
+ ///
+ public override string ToString() =>
+ $"LoginDetails {{ Username = {Username}, Password = {(Password is null ? "" : "")}, "
+ + $"Totp = {(Totp is null ? "" : "")}, Uris = {Uris.Count} }}";
+}
diff --git a/src/Domain/Vault/LoginUri.cs b/src/Domain/Vault/LoginUri.cs
new file mode 100644
index 0000000..fa6efce
--- /dev/null
+++ b/src/Domain/Vault/LoginUri.cs
@@ -0,0 +1,10 @@
+namespace BitwardenSharp.Domain.Vault;
+
+/// One URI on a login, with the match rule Bitwarden should use for it.
+public sealed record LoginUri
+{
+ public required string Uri { get; init; }
+
+ /// Null means "use the vault-wide default match strategy".
+ public UriMatchType? Match { get; init; }
+}
diff --git a/src/Domain/Vault/UriMatchType.cs b/src/Domain/Vault/UriMatchType.cs
new file mode 100644
index 0000000..7105017
--- /dev/null
+++ b/src/Domain/Vault/UriMatchType.cs
@@ -0,0 +1,15 @@
+namespace BitwardenSharp.Domain.Vault;
+
+///
+/// How Bitwarden decides a URI matches the current page. Null on an item means "inherit the
+/// vault default", which is why is nullable rather than defaulted.
+///
+public enum UriMatchType
+{
+ Domain = 0,
+ Host = 1,
+ StartsWith = 2,
+ Exact = 3,
+ RegularExpression = 4,
+ Never = 5,
+}
diff --git a/src/Domain/Vault/VaultFolder.cs b/src/Domain/Vault/VaultFolder.cs
new file mode 100644
index 0000000..cd03921
--- /dev/null
+++ b/src/Domain/Vault/VaultFolder.cs
@@ -0,0 +1,16 @@
+namespace BitwardenSharp.Domain.Vault;
+
+///
+/// A vault folder. Bitwarden has no real hierarchy — nesting is a naming convention, where
+/// "Homelab/Proxmox" is a single folder whose name happens to contain a slash.
+///
+public sealed record VaultFolder
+{
+ public required string Id { get; init; }
+
+ public required string Name { get; init; }
+
+ /// The path segments implied by the name, e.g. ["Homelab", "Proxmox"].
+ public IReadOnlyList Segments =>
+ Name.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+}
diff --git a/src/Domain/Vault/VaultItem.cs b/src/Domain/Vault/VaultItem.cs
new file mode 100644
index 0000000..c7bce75
--- /dev/null
+++ b/src/Domain/Vault/VaultItem.cs
@@ -0,0 +1,86 @@
+namespace BitwardenSharp.Domain.Vault;
+
+///
+/// A single vault entry, mirroring the shape the Bitwarden CLI emits from
+/// bw list items / bw get item.
+///
+///
+/// This is a faithful transport model, not an idealised one: field names and nullability follow
+/// what bw actually produces so a round-trip (get → mutate → edit) preserves everything
+/// the CLI gave us. Dropping an unrecognised field here would silently delete it from the vault
+/// on the next write.
+///
+public sealed record VaultItem
+{
+ public required string Id { get; init; }
+
+ public required ItemType Type { get; init; }
+
+ public required string Name { get; init; }
+
+ public string? FolderId { get; init; }
+
+ public string? OrganizationId { get; init; }
+
+ public string? Notes { get; init; }
+
+ public bool Favorite { get; init; }
+
+ public LoginDetails? Login { get; init; }
+
+ public CardDetails? Card { get; init; }
+
+ public IdentityDetails? Identity { get; init; }
+
+ public SecureNoteDetails? SecureNote { get; init; }
+
+ public SshKeyDetails? SshKey { get; init; }
+
+ public IReadOnlyList Fields { get; init; } = [];
+
+ public IReadOnlyList Attachments { get; init; } = [];
+
+ /// Previous passwords, newest first. Bitwarden keeps these when one is changed.
+ public IReadOnlyList PasswordHistory { get; init; } = [];
+
+ public IReadOnlyList CollectionIds { get; init; } = [];
+
+ public RepromptType Reprompt { get; init; }
+
+ ///
+ /// The item's own encryption key, present on items Bitwarden has migrated to per-cipher keys
+ /// (109 of 818 on the vault this was built against). Opaque here and carried untouched — the
+ /// CLI handles the crypto, but dropping this field on a write would corrupt the item.
+ ///
+ public string? Key { get; init; }
+
+ public DateTimeOffset? RevisionDate { get; init; }
+
+ public DateTimeOffset? CreationDate { get; init; }
+
+ /// Every URI on this item, or empty when it has none.
+ public IReadOnlyList Uris => Login?.Uris ?? [];
+
+ /// Whether the master password must be re-entered before this item is revealed.
+ public bool RequiresReprompt => Reprompt == RepromptType.MasterPassword;
+
+ ///
+ /// How much irreplaceable data this item carries. Used to choose which member of a duplicate
+ /// group survives a merge — the richest one wins, because everything the others hold can be
+ /// copied onto it, but an attachment cannot be moved between items by the CLI at all.
+ ///
+ public int Richness =>
+ Attachments.Count * 10
+ + (string.IsNullOrWhiteSpace(Login?.Totp) ? 0 : 5)
+ + Fields.Count * 3
+ + (string.IsNullOrWhiteSpace(Notes) ? 0 : 2)
+ + (string.IsNullOrWhiteSpace(FolderId) ? 0 : 2)
+ + (Favorite ? 1 : 0)
+ + Uris.Count;
+
+ ///
+ /// Deliberately excludes every payload — see ,
+ /// and , all of which redact in their own right.
+ ///
+ public override string ToString() => $"VaultItem {{ Id = {Id}, Name = {Name}, Type = {Type} }}";
+}
diff --git a/src/Infrastructure/BitwardenSharp.Infrastructure.csproj b/src/Infrastructure/BitwardenSharp.Infrastructure.csproj
new file mode 100644
index 0000000..3d68e78
--- /dev/null
+++ b/src/Infrastructure/BitwardenSharp.Infrastructure.csproj
@@ -0,0 +1,13 @@
+
+
+ Adapters for BitwardenSharp — drives the official `bw` CLI to satisfy the Application's IVaultClient port.
+ bitwarden;vault;cli;password-manager
+
+
+
+
+
+
+
+
+
diff --git a/src/Infrastructure/Bw/BwCliOptions.cs b/src/Infrastructure/Bw/BwCliOptions.cs
new file mode 100644
index 0000000..7ead2dc
--- /dev/null
+++ b/src/Infrastructure/Bw/BwCliOptions.cs
@@ -0,0 +1,19 @@
+namespace BitwardenSharp.Infrastructure.Bw;
+
+/// How to reach the bw client.
+public sealed class BwCliOptions
+{
+ /// Path to the executable. Resolved from PATH when left as the bare name.
+ public string ExecutablePath { get; set; } = "bw";
+
+ ///
+ /// The vault session key.
+ ///
+ ///
+ /// Held in memory for the life of the process and passed to child processes through their
+ /// environment. It is never written to disk and never becomes a command-line argument.
+ /// Defaults to BW_SESSION from the environment, which is how bw unlock --raw
+ /// is normally plumbed through.
+ ///
+ public string? Session { get; set; } = Environment.GetEnvironmentVariable("BW_SESSION");
+}
diff --git a/src/Infrastructure/Bw/BwCliVaultClient.cs b/src/Infrastructure/Bw/BwCliVaultClient.cs
new file mode 100644
index 0000000..fcb8f1b
--- /dev/null
+++ b/src/Infrastructure/Bw/BwCliVaultClient.cs
@@ -0,0 +1,148 @@
+using System.Text;
+using System.Text.Json;
+using BitwardenSharp.Application.Abstractions;
+using BitwardenSharp.Domain.Vault;
+using BitwardenSharp.Infrastructure.Bw.Contracts;
+using Microsoft.Extensions.Logging;
+
+namespace BitwardenSharp.Infrastructure.Bw;
+
+///
+/// An backed by the official bw command-line client.
+///
+///
+/// Every call spawns a process, which costs roughly half a second of Node start-up. That is
+/// irrelevant for the bulk read (one bw list items returns the whole vault) and acceptable
+/// for merges, which are a handful of calls each and are gated on human approval anyway.
+///
+public sealed class BwCliVaultClient(
+ BwProcessRunner runner,
+ ILogger? logger = null) : IVaultClient
+{
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ public async Task SyncAsync(CancellationToken cancellationToken = default) =>
+ await runner.RunAsync(["sync"], cancellationToken: cancellationToken);
+
+ public async Task GetStatusAsync(CancellationToken cancellationToken = default)
+ {
+ var json = await runner.RunAsync(["status"], cancellationToken: cancellationToken);
+ var status = JsonSerializer.Deserialize(json, Json)
+ ?? throw new InvalidOperationException("bw status returned nothing");
+ return new VaultStatus
+ {
+ Status = status.Status,
+ UserEmail = status.UserEmail,
+ ServerUrl = status.ServerUrl,
+ LastSync = status.LastSync,
+ };
+ }
+
+ public async Task> GetItemsAsync(CancellationToken cancellationToken = default)
+ {
+ var json = await runner.RunAsync(["list", "items"], cancellationToken: cancellationToken);
+ var items = JsonSerializer.Deserialize>(json, Json) ?? [];
+ logger?.LogDebug("Read {Count} items from the vault", items.Count);
+ return items.Select(BwItemMapper.ToDomain).ToList();
+ }
+
+ public async Task> GetFoldersAsync(CancellationToken cancellationToken = default)
+ {
+ var json = await runner.RunAsync(["list", "folders"], cancellationToken: cancellationToken);
+ var folders = JsonSerializer.Deserialize>(json, Json) ?? [];
+ return folders.Select(BwItemMapper.ToDomain).ToList();
+ }
+
+ public async Task GetItemAsync(string id, CancellationToken cancellationToken = default) =>
+ BwItemMapper.ToDomain(await GetWireItemAsync(id, cancellationToken));
+
+ public async Task UpdateItemAsync(VaultItem item, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(item);
+
+ // Re-read the wire object and apply onto it: `bw edit` replaces the item wholesale, so
+ // anything absent from the payload is deleted from the vault. See BwItemMapper.ApplyTo.
+ var wire = BwItemMapper.ApplyTo(item, await GetWireItemAsync(item.Id, cancellationToken));
+ var payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(wire, Json));
+
+ // Piped, not passed. The payload contains the password in clear, and process arguments
+ // are readable by any local user through `ps`.
+ var json = await runner.RunAsync(
+ ["edit", "item", item.Id],
+ standardInput: payload,
+ cancellationToken: cancellationToken);
+
+ var updated = JsonSerializer.Deserialize(json, Json)
+ ?? throw new InvalidOperationException($"bw edit item {item.Id} returned nothing");
+ return BwItemMapper.ToDomain(updated);
+ }
+
+ public async Task DeleteItemAsync(
+ string id,
+ bool permanent = false,
+ CancellationToken cancellationToken = default)
+ {
+ string[] args = permanent ? ["delete", "item", id, "--permanent"] : ["delete", "item", id];
+ await runner.RunAsync(args, cancellationToken: cancellationToken);
+ logger?.LogInformation(
+ "Deleted item {ItemId} ({Disposition})", id, permanent ? "permanently" : "to trash");
+ }
+
+ public async Task CreateItemAsync(
+ VaultItem item, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(item);
+
+ var wire = BwItemMapper.ApplyTo(item, BwItemMapper.NewWireItem(item));
+ var payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(wire, Json));
+
+ // Piped, not passed: the payload carries the password in clear.
+ var json = await runner.RunAsync(
+ ["create", "item"], standardInput: payload, cancellationToken: cancellationToken);
+
+ return BwItemMapper.ToDomain(
+ JsonSerializer.Deserialize(json, Json)
+ ?? throw new InvalidOperationException("bw create item returned nothing"));
+ }
+
+ public async Task CreateFolderAsync(
+ string name, CancellationToken cancellationToken = default)
+ {
+ var payload = Convert.ToBase64String(
+ JsonSerializer.SerializeToUtf8Bytes(new BwFolder { Name = name }, Json));
+ var json = await runner.RunAsync(
+ ["create", "folder"], standardInput: payload, cancellationToken: cancellationToken);
+ return BwItemMapper.ToDomain(
+ JsonSerializer.Deserialize(json, Json)
+ ?? throw new InvalidOperationException("bw create folder returned nothing"));
+ }
+
+ public async Task RenameFolderAsync(
+ string id, string name, CancellationToken cancellationToken = default)
+ {
+ var payload = Convert.ToBase64String(
+ JsonSerializer.SerializeToUtf8Bytes(new BwFolder { Id = id, Name = name }, Json));
+ var json = await runner.RunAsync(
+ ["edit", "folder", id], standardInput: payload, cancellationToken: cancellationToken);
+ return BwItemMapper.ToDomain(
+ JsonSerializer.Deserialize(json, Json)
+ ?? throw new InvalidOperationException($"bw edit folder {id} returned nothing"));
+ }
+
+ public async Task DeleteFolderAsync(string id, CancellationToken cancellationToken = default)
+ {
+ await runner.RunAsync(["delete", "folder", id], cancellationToken: cancellationToken);
+ logger?.LogInformation("Deleted folder {FolderId}", id);
+ }
+
+ private async Task GetWireItemAsync(string id, CancellationToken cancellationToken)
+ {
+ var json = await runner.RunAsync(["get", "item", id], cancellationToken: cancellationToken);
+ return JsonSerializer.Deserialize(json, Json)
+ ?? throw new InvalidOperationException($"bw get item {id} returned nothing");
+ }
+}
diff --git a/src/Infrastructure/Bw/BwItemMapper.cs b/src/Infrastructure/Bw/BwItemMapper.cs
new file mode 100644
index 0000000..177864f
--- /dev/null
+++ b/src/Infrastructure/Bw/BwItemMapper.cs
@@ -0,0 +1,160 @@
+using BitwardenSharp.Domain.Vault;
+using BitwardenSharp.Infrastructure.Bw.Contracts;
+
+namespace BitwardenSharp.Infrastructure.Bw;
+
+/// Translates between the bw wire shape and the domain model.
+internal static class BwItemMapper
+{
+ public static VaultItem ToDomain(BwItem wire) => new()
+ {
+ Id = wire.Id,
+ Type = (ItemType)wire.Type,
+ Name = wire.Name,
+ FolderId = wire.FolderId,
+ OrganizationId = wire.OrganizationId,
+ Notes = wire.Notes,
+ Favorite = wire.Favorite,
+ RevisionDate = wire.RevisionDate,
+ CreationDate = wire.CreationDate,
+ Reprompt = (RepromptType)(wire.Reprompt ?? 0),
+ CollectionIds = wire.CollectionIds ?? [],
+ Key = wire.Key,
+ PasswordHistory = wire.PasswordHistory?
+ .Select(h => new PasswordHistoryEntry { Password = h.Password, LastUsedDate = h.LastUsedDate })
+ .ToList() ?? [],
+ Card = wire.Card is null ? null : new CardDetails
+ {
+ CardholderName = wire.Card.CardholderName,
+ Brand = wire.Card.Brand,
+ Number = wire.Card.Number,
+ ExpMonth = wire.Card.ExpMonth,
+ ExpYear = wire.Card.ExpYear,
+ Code = wire.Card.Code,
+ },
+ Identity = wire.Identity is null ? null : new IdentityDetails
+ {
+ Title = wire.Identity.Title,
+ FirstName = wire.Identity.FirstName,
+ MiddleName = wire.Identity.MiddleName,
+ LastName = wire.Identity.LastName,
+ Address1 = wire.Identity.Address1,
+ Address2 = wire.Identity.Address2,
+ Address3 = wire.Identity.Address3,
+ City = wire.Identity.City,
+ State = wire.Identity.State,
+ PostalCode = wire.Identity.PostalCode,
+ Country = wire.Identity.Country,
+ Company = wire.Identity.Company,
+ Email = wire.Identity.Email,
+ Phone = wire.Identity.Phone,
+ Ssn = wire.Identity.Ssn,
+ Username = wire.Identity.Username,
+ PassportNumber = wire.Identity.PassportNumber,
+ LicenseNumber = wire.Identity.LicenseNumber,
+ },
+ SecureNote = wire.SecureNote is null
+ ? null
+ : new SecureNoteDetails { Type = (SecureNoteType)wire.SecureNote.Type },
+ SshKey = wire.SshKey is null ? null : new SshKeyDetails
+ {
+ PrivateKey = wire.SshKey.PrivateKey,
+ PublicKey = wire.SshKey.PublicKey,
+ KeyFingerprint = wire.SshKey.KeyFingerprint,
+ },
+ Login = wire.Login is null ? null : new LoginDetails
+ {
+ Username = wire.Login.Username,
+ Password = wire.Login.Password,
+ Totp = wire.Login.Totp,
+ PasswordRevisionDate = wire.Login.PasswordRevisionDate,
+ Uris = wire.Login.Uris?
+ .Where(u => !string.IsNullOrWhiteSpace(u.Uri))
+ .Select(u => new LoginUri { Uri = u.Uri!, Match = (UriMatchType?)u.Match })
+ .ToList() ?? [],
+ },
+ Fields = wire.Fields?
+ .Select(f => new CustomField
+ {
+ Name = f.Name,
+ Value = f.Value,
+ Type = (FieldType)f.Type,
+ LinkedId = f.LinkedId,
+ })
+ .ToList() ?? [],
+ Attachments = wire.Attachments?
+ .Select(a => new ItemAttachment
+ {
+ Id = a.Id,
+ FileName = a.FileName,
+ Size = long.TryParse(a.Size, out var size) ? size : null,
+ })
+ .ToList() ?? [],
+ };
+
+ ///
+ /// A blank wire object for an item that does not exist yet.
+ ///
+ ///
+ /// Only the discriminators the server needs in order to accept the create; everything else is
+ /// filled by . No id, and no per-cipher key — both are the vault's to
+ /// assign, and sending a borrowed one would attach the new item to another item's key.
+ ///
+ public static BwItem NewWireItem(VaultItem item) => new()
+ {
+ Type = (int)item.Type,
+ Name = item.Name,
+ Login = item.Type == ItemType.Login ? new BwLogin() : null,
+ SecureNote = item.Type == ItemType.SecureNote ? new BwSecureNote() : null,
+ Card = item.Type == ItemType.Card ? new BwCard() : null,
+ Identity = item.Type == ItemType.Identity ? new BwIdentity() : null,
+ };
+
+ ///
+ /// Writes the domain item's mutable state onto the wire object it came from and returns it.
+ ///
+ ///
+ /// Mutating the original rather than building a fresh is what preserves
+ /// collectionIds, reprompt, fido2Credentials and anything a newer CLI has
+ /// added that landed in extension data. A bw edit replaces the stored item outright, so
+ /// a field absent from the payload is a field deleted from the vault.
+ ///
+ public static BwItem ApplyTo(VaultItem item, BwItem wire)
+ {
+ wire.Name = item.Name;
+ wire.FolderId = item.FolderId;
+ wire.Notes = item.Notes;
+ wire.Favorite = item.Favorite;
+
+ wire.Fields = item.Fields.Count == 0
+ ? null
+ : item.Fields.Select(f => new BwField
+ {
+ Name = f.Name,
+ Value = f.Value,
+ Type = (int)f.Type,
+ LinkedId = f.LinkedId,
+ }).ToList();
+
+ if (item.Login is not null)
+ {
+ wire.Login ??= new BwLogin();
+ wire.Login.Username = item.Login.Username;
+ wire.Login.Password = item.Login.Password;
+ wire.Login.Totp = item.Login.Totp;
+ wire.Login.Uris = item.Login.Uris.Count == 0
+ ? null
+ : item.Login.Uris.Select(u => new BwUri { Uri = u.Uri, Match = (int?)u.Match }).ToList();
+ }
+
+ return wire;
+ }
+
+ public static VaultFolder ToDomain(BwFolder wire) => new()
+ {
+ // The pseudo-folder "No Folder" comes back with a null id; represent it as empty so it
+ // is a value rather than a hole.
+ Id = wire.Id ?? string.Empty,
+ Name = wire.Name,
+ };
+}
diff --git a/src/Infrastructure/Bw/BwProcessRunner.cs b/src/Infrastructure/Bw/BwProcessRunner.cs
new file mode 100644
index 0000000..3e84d11
--- /dev/null
+++ b/src/Infrastructure/Bw/BwProcessRunner.cs
@@ -0,0 +1,131 @@
+using System.Diagnostics;
+using System.Text;
+using Microsoft.Extensions.Logging;
+
+namespace BitwardenSharp.Infrastructure.Bw;
+
+/// The result of one bw invocation.
+public sealed record BwResult(int ExitCode, string StandardOutput, string StandardError)
+{
+ public bool Succeeded => ExitCode == 0;
+}
+
+/// Raised when bw exits non-zero.
+public sealed class BwCommandException(string command, BwResult result)
+ : Exception($"`bw {command}` failed with exit code {result.ExitCode}: {Summarise(result.StandardError)}")
+{
+ public BwResult Result { get; } = result;
+
+ private static string Summarise(string stderr)
+ {
+ var text = stderr.Trim();
+ return text.Length <= 400 ? text : text[..400] + "…";
+ }
+}
+
+///
+/// Runs the official bw client.
+///
+///
+///
+/// Two rules hold everywhere in this class, and both exist because the arguments of a running
+/// process are world-readable via ps on every platform this ships to:
+///
+///
+///
+/// Arguments go through , never a joined string. The
+/// runtime quotes each element correctly per platform, so nothing has to be escaped by hand.
+///
+///
+/// Secrets never become arguments. The session key travels in the environment, and the
+/// base64 item payload for edit/create — which contains the password in clear —
+/// is piped to stdin, which bw accepts in place of the positional argument.
+///
+///
+///
+public sealed class BwProcessRunner(BwCliOptions options, ILogger? logger = null)
+{
+ /// Runs bw and returns its output, throwing if it exits non-zero.
+ public async Task RunAsync(
+ IEnumerable arguments,
+ string? standardInput = null,
+ IReadOnlyDictionary? environment = null,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await TryRunAsync(arguments, standardInput, environment, cancellationToken);
+ if (!result.Succeeded) throw new BwCommandException(DescribeSafely(arguments), result);
+ return result.StandardOutput;
+ }
+
+ /// Runs bw and returns the result without throwing on a non-zero exit.
+ public async Task TryRunAsync(
+ IEnumerable arguments,
+ string? standardInput = null,
+ IReadOnlyDictionary? environment = null,
+ CancellationToken cancellationToken = default)
+ {
+ var args = arguments.ToList();
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = options.ExecutablePath,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = standardInput is not null,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ foreach (var arg in args) startInfo.ArgumentList.Add(arg);
+
+ // The session key is a decryption key for the whole vault. It goes in the environment of
+ // the child alone — not the command line, and not this process's own environment.
+ if (!string.IsNullOrEmpty(options.Session)) startInfo.Environment["BW_SESSION"] = options.Session;
+
+ // bw asks for input on a TTY when it wants a password. Nothing here should ever reach
+ // that state, and if it does we want a clean failure rather than a hung process.
+ startInfo.Environment["BW_NOINTERACTION"] = "true";
+
+ // Caller-supplied values -- currently the master password for `unlock --passwordenv`.
+ // Set on the child only; this process's own environment is never touched.
+ if (environment is not null)
+ foreach (var (name, value) in environment) startInfo.Environment[name] = value;
+
+ logger?.LogDebug("Running bw {Arguments}", DescribeSafely(args));
+
+ using var process = new Process { StartInfo = startInfo };
+ if (!process.Start())
+ throw new InvalidOperationException($"could not start '{options.ExecutablePath}'");
+
+ var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var stderr = process.StandardError.ReadToEndAsync(cancellationToken);
+
+ if (standardInput is not null)
+ {
+ await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken);
+ process.StandardInput.Close();
+ }
+
+ await process.WaitForExitAsync(cancellationToken);
+ return new BwResult(process.ExitCode, (await stdout).Trim(), (await stderr).Trim());
+ }
+
+ ///
+ /// Renders an argument list for logging with anything past a known-safe verb elided, so a
+ /// debug log can never become a record of item ids or payloads.
+ ///
+ private static string DescribeSafely(IEnumerable arguments)
+ {
+ var args = arguments.ToList();
+ var safe = new StringBuilder();
+ for (var i = 0; i < args.Count; i++)
+ {
+ // The first two tokens are the verb and object ("get item", "list items"); anything
+ // after them can identify a specific secret.
+ safe.Append(i < 2 ? args[i] : "<…>");
+ if (i < args.Count - 1) safe.Append(' ');
+ if (i >= 2) break;
+ }
+ return safe.ToString();
+ }
+}
diff --git a/src/Infrastructure/Bw/BwVaultSession.cs b/src/Infrastructure/Bw/BwVaultSession.cs
new file mode 100644
index 0000000..bdda440
--- /dev/null
+++ b/src/Infrastructure/Bw/BwVaultSession.cs
@@ -0,0 +1,78 @@
+using System.Text.Json;
+using BitwardenSharp.Application.Abstractions;
+using BitwardenSharp.Infrastructure.Bw.Contracts;
+using Microsoft.Extensions.Logging;
+
+namespace BitwardenSharp.Infrastructure.Bw;
+
+///
+/// Unlocks the vault through the bw client and holds the resulting session key.
+///
+///
+///
+/// bw unlock offers three ways to supply a password. Two are unacceptable here:
+/// the positional argument puts it in argv, where ps shows it to every local user,
+/// and --passwordfile writes it to disk. This uses --passwordenv, which names an
+/// environment variable set on the child process only — never on ours, and never written down.
+///
+///
+/// The variable name is randomised per attempt so nothing can be primed to read a fixed one, and
+/// the value is dropped as soon as the process exits.
+///
+///
+public sealed class BwVaultSession(
+ BwProcessRunner runner,
+ BwCliOptions options,
+ ILogger? logger = null) : IVaultSession
+{
+ private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
+
+ public async Task GetStatusAsync(CancellationToken cancellationToken = default)
+ {
+ var json = await runner.RunAsync(["status"], cancellationToken: cancellationToken);
+ var status = JsonSerializer.Deserialize(json, Json)
+ ?? throw new InvalidOperationException("bw status returned nothing");
+ return new VaultStatus
+ {
+ Status = status.Status,
+ UserEmail = status.UserEmail,
+ ServerUrl = status.ServerUrl,
+ LastSync = status.LastSync,
+ };
+ }
+
+ public async Task UnlockAsync(
+ string masterPassword,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrEmpty(masterPassword)) return UnlockResult.Failure("Enter your master password.");
+
+ var variable = $"BWSHARP_MP_{Guid.NewGuid():N}";
+ var result = await runner.TryRunAsync(
+ ["unlock", "--raw", "--passwordenv", variable],
+ environment: new Dictionary { [variable] = masterPassword },
+ cancellationToken: cancellationToken);
+
+ if (!result.Succeeded)
+ {
+ logger?.LogWarning("Unlock rejected (exit {ExitCode})", result.ExitCode);
+ // bw's own message is safe to surface: it says the password is wrong, not what it is.
+ var error = result.StandardError.Trim();
+ return UnlockResult.Failure(error.Length == 0 ? "Could not unlock the vault." : error);
+ }
+
+ var session = result.StandardOutput.Trim();
+ if (session.Length == 0) return UnlockResult.Failure("bw returned an empty session key.");
+
+ options.Session = session;
+ logger?.LogInformation("Vault unlocked");
+ return UnlockResult.Success();
+ }
+
+ public async Task LockAsync(CancellationToken cancellationToken = default)
+ {
+ await runner.TryRunAsync(["lock"], cancellationToken: cancellationToken);
+ options.Session = null;
+ logger?.LogInformation("Vault locked");
+ }
+}
diff --git a/src/Infrastructure/Bw/Contracts/BwContracts.cs b/src/Infrastructure/Bw/Contracts/BwContracts.cs
new file mode 100644
index 0000000..2947b9c
--- /dev/null
+++ b/src/Infrastructure/Bw/Contracts/BwContracts.cs
@@ -0,0 +1,156 @@
+using System.Text.Json.Serialization;
+
+namespace BitwardenSharp.Infrastructure.Bw.Contracts;
+
+///
+/// The wire shape of a vault item as the bw CLI emits and accepts it.
+///
+///
+/// Kept separate from the domain model on purpose. An item written back to the vault replaces the
+/// stored one wholesale, so this type has to round-trip every property bw gave us,
+/// including ones the domain has no opinion about. catches anything
+/// added by a future CLI version, so an unrecognised field survives a merge instead of being
+/// silently deleted from the vault.
+///
+public sealed class BwItem
+{
+ [JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
+ [JsonPropertyName("organizationId")] public string? OrganizationId { get; set; }
+ [JsonPropertyName("folderId")] public string? FolderId { get; set; }
+ [JsonPropertyName("type")] public int Type { get; set; }
+ [JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
+ [JsonPropertyName("notes")] public string? Notes { get; set; }
+ [JsonPropertyName("favorite")] public bool Favorite { get; set; }
+ [JsonPropertyName("login")] public BwLogin? Login { get; set; }
+ [JsonPropertyName("card")] public BwCard? Card { get; set; }
+ [JsonPropertyName("identity")] public BwIdentity? Identity { get; set; }
+ [JsonPropertyName("secureNote")] public BwSecureNote? SecureNote { get; set; }
+ [JsonPropertyName("sshKey")] public BwSshKey? SshKey { get; set; }
+ [JsonPropertyName("passwordHistory")] public List? PasswordHistory { get; set; }
+
+ /// Per-cipher key. Opaque, and fatal to drop on a write.
+ [JsonPropertyName("key")] public string? Key { get; set; }
+ [JsonPropertyName("fields")] public List? Fields { get; set; }
+ [JsonPropertyName("attachments")] public List? Attachments { get; set; }
+ [JsonPropertyName("collectionIds")] public List? CollectionIds { get; set; }
+ [JsonPropertyName("revisionDate")] public DateTimeOffset? RevisionDate { get; set; }
+ [JsonPropertyName("creationDate")] public DateTimeOffset? CreationDate { get; set; }
+ [JsonPropertyName("deletedDate")] public DateTimeOffset? DeletedDate { get; set; }
+ [JsonPropertyName("reprompt")] public int? Reprompt { get; set; }
+
+ /// Anything this version of the model does not name, preserved verbatim.
+ [JsonExtensionData] public Dictionary? ExtensionData { get; set; }
+}
+
+public sealed class BwLogin
+{
+ [JsonPropertyName("username")] public string? Username { get; set; }
+ [JsonPropertyName("password")] public string? Password { get; set; }
+ [JsonPropertyName("totp")] public string? Totp { get; set; }
+ [JsonPropertyName("uris")] public List? Uris { get; set; }
+ [JsonPropertyName("passwordRevisionDate")] public DateTimeOffset? PasswordRevisionDate { get; set; }
+ [JsonPropertyName("fido2Credentials")] public List