Compare commits

..
Author SHA1 Message Date
zhom b1c4559b74 chore: linting 2026-08-11 00:18:49 +04:00
zhom 5afde36790 chore: version bump 2026-08-10 21:25:22 +04:00
zhom 325d8fae31 refactor: cleanup sync 2026-08-10 15:47:31 +04:00
zhom 929f5a0ead chore: linting 2026-08-10 09:31:42 +04:00
zhom 32a1728dee refactor: profile imports 2026-08-10 09:19:25 +04:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
a6b79341b3 chore: update flake.nix for v0.29.1 [skip ci] (#546)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 21:25:33 +00:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
a6b4108d82 docs: update CHANGELOG.md and README.md for v0.29.1 [skip ci] (#545)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 21:25:11 +00:00
zhom 11b130df46 chore: linting 2026-08-08 23:50:12 +04:00
zhom b8e5b4f4e6 chore: update pnpm 2026-08-08 22:29:53 +04:00
zhom d80e127cd3 chore: switch to ai-inference v3 and fail workflows on 410 2026-08-08 22:29:01 +04:00
zhom e11967509d chore: version bump 2026-08-08 22:28:35 +04:00
zhom 6d9a44faad fix: prevent settings page from crashing on some systems 2026-08-08 20:36:51 +04:00
zhom f8532be8af refactor: update logic and locks around vpn extensions 2026-08-08 19:27:39 +04:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
70a8deb7eb chore: update flake.nix for v0.29.0 [skip ci] (#542)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 14:11:50 +00:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
b89f002c1d docs: update CHANGELOG.md and README.md for v0.29.0 [skip ci] (#541)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 14:11:35 +00:00
zhom 3b1feb3f1b chore: version bump 2026-08-08 16:35:46 +04:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
bc2b93d902 docs: update CHANGELOG.md and README.md for v0.29.0 [skip ci] (#539)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 12:26:50 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5c24e84eaf ci(deps): bump the github-actions group with 3 updates (#538)
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [anomalyco/opencode/github](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `pnpm/action-setup` from 6.0.9 to 6.0.10
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0ebf47130e4866e96fce0953f49152a61190b271...0977fd99725f1db4007ccb2928dbb4e90d06cc86)

Updates `anomalyco/opencode/github` from 1.18.9 to 1.18.14
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/4da7bb44c84e013fa53e9c5d02ac753d1435c81a...65cf14df16c191f3e9684f0d9a8bae69103ced6d)

Updates `crate-ci/typos` from 1.48.0 to 1.49.0
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/bee27e3a4fd1ea2111cf90ab89cd076c870fce14...8a48f81b6c64dcfea44b3633223084c4be58ac5f)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 09:08:52 +00:00
zhom ffbbaa732a chore: linting 2026-08-07 06:16:00 -07:00
zhom f12a84e18f refactor: cleanup 2026-08-06 14:39:53 -07:00
zhom 39bbdcb547 feat: prevent launch with inconsistent geodata 2026-08-05 21:39:10 -07:00
zhom 29cb83d063 refactor: cleanup 2026-08-03 18:44:24 +04:00
126 changed files with 19401 additions and 2079 deletions
+6 -1
View File
@@ -30,4 +30,9 @@ messages:
### Documentation
### Dependencies
### Developer Experience
model: openai/gpt-4.1
# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is
# the only value valid on every Copilot plan (Free and Student get auto
# selection only), and it cannot go stale the way `openai/gpt-4.1` did when
# GitHub Models was retired on 2026-07-30 and took both of these workflows
# down with it.
model: auto
@@ -20,4 +20,9 @@ messages:
{{commits}}
Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else.
model: openai/gpt-4.1
# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is
# the only value valid on every Copilot plan (Free and Student get auto
# selection only), and it cannot go stale the way `openai/gpt-4.1` did when
# GitHub Models was retired on 2026-07-30 and took both of these workflows
# down with it.
model: auto
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
+17 -2
View File
@@ -96,7 +96,8 @@ jobs:
-d "$PAYLOAD" || echo "000")
if [ "$STATUS" != "200" ]; then
echo "::warning::GitHub Models returned HTTP $STATUS; treating as compliant"
echo "::error::GitHub Models returned HTTP $STATUS; treating as compliant"
printf '%s\n' "inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
exit 0
fi
@@ -107,7 +108,8 @@ jobs:
# to a compliant result so a flaky model never closes a legitimate issue.
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; treating as compliant"
echo "::error::Model returned non-JSON; treating as compliant"
printf '%s\n' "model returned output that was not JSON" >> /tmp/ai-degraded
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
fi
echo "Compliance response validated"
@@ -145,3 +147,16 @@ jobs:
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned"
# The steps above deliberately degrade rather than block: an inference
# outage must never close a contributor's issue or flag their pull
# request. But a run that skipped the check it exists to perform has not
# succeeded, and reporting green hides that the automation is dead.
- name: Fail if the AI check did not actually run
if: always()
run: |
if [ -f /tmp/ai-degraded ]; then
echo "::error::This check degraded to a no-op and its result was not verified:"
sed 's/^/ - /' /tmp/ai-degraded
exit 1
fi
+41 -7
View File
@@ -257,7 +257,8 @@ jobs:
if [ "$STATUS" = "200" ]; then
jq -r '.choices[0].message.content // empty' /tmp/triage-response.json > /tmp/triage-raw.txt || : > /tmp/triage-raw.txt
else
echo "::warning::GitHub Models returned HTTP $STATUS for triage"
echo "::error::GitHub Models returned HTTP $STATUS for triage"
printf '%s\n' "triage inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
: > /tmp/triage-raw.txt
fi
@@ -266,7 +267,8 @@ jobs:
# Fall back to a safe classification when the response is not JSON.
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
echo "::warning::Triage returned non-JSON; using fallback classification"
echo "::error::Triage returned non-JSON; using fallback classification"
printf '%s\n' "triage returned output that was not JSON" >> /tmp/ai-degraded
jq -n '{
language: "en",
classification: "bug-in-scope",
@@ -436,7 +438,8 @@ jobs:
-d "$PAYLOAD" || echo "000")
if [ "$STATUS" != "200" ]; then
echo "::warning::GitHub Models returned HTTP $STATUS; skipping the triage comment"
echo "::error::GitHub Models returned HTTP $STATUS; skipping the triage comment"
printf '%s\n' "composer inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
echo "has_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -444,7 +447,8 @@ jobs:
jq -r '.choices[0].message.content // empty' /tmp/compose-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::warning::Composer returned empty response; skipping the triage comment"
echo "::error::Composer returned empty response; skipping the triage comment"
printf '%s\n' "composer returned an empty response" >> /tmp/ai-degraded
echo "has_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -482,6 +486,20 @@ jobs:
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
# The steps above deliberately degrade rather than block: an inference
# outage must never close a contributor's issue or flag their pull
# request. But a run that skipped the check it exists to perform has not
# succeeded, and reporting green hides that the automation is dead.
- name: Fail if the AI check did not actually run
if: always()
run: |
if [ -f /tmp/ai-degraded ]; then
echo "::error::This check degraded to a no-op and its result was not verified:"
sed 's/^/ - /' /tmp/ai-degraded
exit 1
fi
analyze-pr:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
@@ -619,7 +637,8 @@ jobs:
-d "$PAYLOAD" || echo "000")
if [ "$STATUS" != "200" ]; then
echo "::warning::GitHub Models returned HTTP $STATUS; skipping the review comment"
echo "::error::GitHub Models returned HTTP $STATUS; skipping the review comment"
printf '%s\n' "PR review inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
echo "has_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -627,7 +646,8 @@ jobs:
jq -r '.choices[0].message.content // empty' /tmp/pr-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::warning::AI response was empty; skipping the review comment"
echo "::error::AI response was empty; skipping the review comment"
printf '%s\n' "PR review returned an empty response" >> /tmp/ai-degraded
echo "has_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -642,6 +662,20 @@ jobs:
run: |
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
# The steps above deliberately degrade rather than block: an inference
# outage must never close a contributor's issue or flag their pull
# request. But a run that skipped the check it exists to perform has not
# succeeded, and reporting green hides that the automation is dead.
- name: Fail if the AI check did not actually run
if: always()
run: |
if [ -f /tmp/ai-degraded ]; then
echo "::error::This check degraded to a no-op and its result was not verified:"
sed 's/^/ - /' /tmp/ai-degraded
exit 1
fi
opencode-command:
if: |
github.repository == 'zhom/donutbrowser' &&
@@ -659,7 +693,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Run opencode
uses: anomalyco/opencode/github@4da7bb44c84e013fa53e9c5d02ac753d1435c81a #v1.18.9
uses: anomalyco/opencode/github@65cf14df16c191f3e9684f0d9a8bae69103ced6d #v1.18.14
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
+13 -3
View File
@@ -22,7 +22,7 @@ on:
permissions:
contents: read
models: read
copilot-requests: write
jobs:
notify:
@@ -123,17 +123,27 @@ jobs:
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
# The Copilot CLI is not preinstalled on GitHub-hosted runners, and
# ai-inference v3 shells out to it.
- name: Install Copilot CLI
if: steps.gate.outputs.skip != 'true'
run: npm install -g @github/copilot
- name: Generate summary with AI
id: ai
if: steps.gate.outputs.skip != 'true'
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
with:
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
input: |
version: ${{ steps.tag.outputs.tag }}
file_input: |
commits: ./commits.txt
max-tokens: 1024
env:
# The Copilot CLI reads its credential from the environment; the
# workflow token carries it under the `copilot-requests` permission
# granted above, so no PAT is needed.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release announcement to Telegram
if: steps.gate.outputs.skip != 'true'
+17 -2
View File
@@ -134,7 +134,8 @@ jobs:
-d "$PAYLOAD" || echo "000")
if [ "$STATUS" != "200" ]; then
echo "::warning::GitHub Models returned HTTP $STATUS; treating as compliant"
echo "::error::GitHub Models returned HTTP $STATUS; treating as compliant"
printf '%s\n' "inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
echo '{"compliant": true, "violations": []}' > /tmp/result.json
exit 0
fi
@@ -146,7 +147,8 @@ jobs:
# The deterministic trailer scan still stands on its own below.
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; treating as compliant"
echo "::error::Model returned non-JSON; treating as compliant"
printf '%s\n' "model returned output that was not JSON" >> /tmp/ai-degraded
echo '{"compliant": true, "violations": []}' > /tmp/result.json
fi
echo "Policy response validated"
@@ -205,3 +207,16 @@ jobs:
run: |
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
gh pr close "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"
# The steps above deliberately degrade rather than block: an inference
# outage must never close a contributor's issue or flag their pull
# request. But a run that skipped the check it exists to perform has not
# succeeded, and reporting green hides that the automation is dead.
- name: Fail if the AI check did not actually run
if: always()
run: |
if [ -f /tmp/ai-degraded ]; then
echo "::error::This check degraded to a no-op and its result was not verified:"
sed 's/^/ - /' /tmp/ai-degraded
exit 1
fi
+13 -3
View File
@@ -8,7 +8,7 @@ on:
permissions:
contents: write
models: read
copilot-requests: write
jobs:
generate-release-notes:
@@ -79,17 +79,27 @@ jobs:
echo "commits-file=commits.txt" >> $GITHUB_OUTPUT
echo "changes-file=changes.txt" >> $GITHUB_OUTPUT
# The Copilot CLI is not preinstalled on GitHub-hosted runners, and
# ai-inference v3 shells out to it.
- name: Install Copilot CLI
if: steps.get-release.outputs.is-prerelease == 'false'
run: npm install -g @github/copilot
- name: Generate release notes with AI
id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
with:
prompt-file: .github/prompts/release-notes.prompt.yml
input: |
version: ${{ steps.get-previous-tag.outputs.current-tag }}
file_input: |
commits: ./commits.txt
max-tokens: 4096
env:
# The Copilot CLI reads its credential from the environment; the
# workflow token carries it under the `copilot-requests` permission
# granted above, so no PAT is needed.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update release with generated notes
if: steps.get-release.outputs.is-prerelease == 'false'
+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
+1 -1
View File
@@ -111,7 +111,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Spell Check Repo
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f #v1.49.0
+7
View File
@@ -64,3 +64,10 @@ nodecar/nodecar-bin
# claude
.claude/
# Claude Code session-recovery runtime state
HANDOFF.md
.claude/settings.local.json
.claude/rate-limit-state.json
.claude/stop-failure-events.jsonl
.claude/quota-blocked.json
session-recover.yaml
+151
View File
@@ -1,6 +1,157 @@
# Changelog
## v0.29.1 (2026-08-08)
### Bug Fixes
- prevent settings page from crashing on some systems
### Refactoring
- update logic and locks around vpn extensions
### Maintenance
- chore: linting
- chore: update pnpm
- chore: switch to ai-inference v3 and fail workflows on 410
- chore: version bump
- chore: update flake.nix for v0.29.0 [skip ci] (#542)
## v0.29.0 (2026-08-08)
### Features
- prevent launch with inconsistent geodata
- cookie bot
- remote sessions
- xray support
- mass import via gui, api, and mcp
- add Turkish (tr) language support
### Bug Fixes
- properly handle x-amz-meta-updated-at
- improve UI interactions and page consistency
### Refactoring
- cleanup
- cleanup
- improve proxy lifetime management
- cleanup
- remote cleanup
- cleanup cloud sync
- cleanup
- harden tests
- block windows app update if the browser is running
- ui refresh
### Documentation
- update CHANGELOG.md and README.md for v0.29.0 [skip ci] (#539)
- contrib-readme-action has updated readme
- contrib-readme-action has updated readme
### Maintenance
- chore: version bump
- ci(deps): bump the github-actions group with 3 updates (#538)
- chore: linting
- chore: linting
- chore: linting
- chore: ci
- chore: upload sidecars to cdn
- chore: linting
- ci(deps): bump the github-actions group with 4 updates
- chore: linting
- chore: disable e2e in ci
- chore: linting
- chore: linting
- chore: ai compliance
- chore: linting
- ci(deps): bump the github-actions group across 1 directory with 3 updates (#514)
- chore: linting
- chore: linting
- chore: add cross-platform webdriver tests
- ci(deps): bump the github-actions group with 2 updates
- chore: update flake.nix for v0.28.2 [skip ci] (#501)
### Other
- deps(deps): bump next from 16.2.10 to 16.2.11 (#515)
- refactors: animations cleanup
- restore settings redirect
- fix group create translation key
## v0.29.0 (2026-08-08)
### Features
- prevent launch with inconsistent geodata
- cookie bot
- remote sessions
- xray support
- mass import via gui, api, and mcp
- add Turkish (tr) language support
### Bug Fixes
- properly handle x-amz-meta-updated-at
- improve UI interactions and page consistency
### Refactoring
- cleanup
- cleanup
- improve proxy lifetime management
- cleanup
- remote cleanup
- cleanup cloud sync
- cleanup
- harden tests
- block windows app update if the browser is running
- ui refresh
### Documentation
- contrib-readme-action has updated readme
- contrib-readme-action has updated readme
### Maintenance
- ci(deps): bump the github-actions group with 3 updates (#538)
- chore: linting
- chore: linting
- chore: linting
- chore: ci
- chore: upload sidecars to cdn
- chore: linting
- ci(deps): bump the github-actions group with 4 updates
- chore: linting
- chore: disable e2e in ci
- chore: linting
- chore: linting
- chore: ai compliance
- chore: linting
- ci(deps): bump the github-actions group across 1 directory with 3 updates (#514)
- chore: linting
- chore: linting
- chore: add cross-platform webdriver tests
- ci(deps): bump the github-actions group with 2 updates
- chore: update flake.nix for v0.28.2 [skip ci] (#501)
### Other
- deps(deps): bump next from 16.2.10 to 16.2.11 (#515)
- refactors: animations cleanup
- restore settings redirect
- fix group create translation key
## v0.28.2 (2026-07-12)
### Features
+5 -5
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_x64.dmg) |
Or install via Homebrew:
@@ -56,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut-0.29.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut-0.29.1-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+4
View File
@@ -12,3 +12,7 @@ extend-exclude = [
[default.extend-words]
DBE = "DBE"
nd = "nd"
[default.extend-identifiers]
# Chrome Web Store extension name in the known-VPN list.
VeePN = "VeePN"
+9
View File
@@ -13,3 +13,12 @@ S3_ACCESS_KEY_ID=CHANGE_ME
S3_SECRET_ACCESS_KEY=CHANGE_ME
S3_BUCKET=donut-sync
S3_FORCE_PATH_STYLE=true
# The address Donut Browser is sent to for file transfers. Set this whenever
# S3_ENDPOINT is only reachable from the server — running MinIO in the same
# compose file makes S3_ENDPOINT a container name like http://minio:9000, which
# resolves on the container network and nowhere else. Presigned URLs are signed
# against the host they name, so leaving this unset there hands every client a
# URL it cannot open: /health and /readyz stay green while every transfer fails.
# Defaults to S3_ENDPOINT, which is correct when storage is already public.
# S3_PUBLIC_ENDPOINT=https://storage.example.com
+13 -3
View File
@@ -19,15 +19,25 @@ export class AppController {
return { status: "ok" };
}
// `storageEndpoint` is the host clients are handed in presigned URLs. The
// server cannot tell whether a client can reach it, so report it and let
// whoever is debugging a failing sync compare it against their network.
// Self-hosted only — see getDiagnosticStorageEndpoint.
@Get("readyz")
async getReadiness(): Promise<{ status: string; s3: boolean }> {
async getReadiness(): Promise<{
status: string;
s3: boolean;
storageEndpoint?: string;
}> {
const s3Ready = await this.syncService.checkS3Connectivity();
const storageEndpoint = this.syncService.getDiagnosticStorageEndpoint();
const diagnostic = storageEndpoint ? { storageEndpoint } : {};
if (!s3Ready) {
throw new HttpException(
{ status: "not ready", s3: false },
{ status: "not ready", s3: false, ...diagnostic },
HttpStatus.SERVICE_UNAVAILABLE,
);
}
return { status: "ready", s3: true };
return { status: "ready", s3: true, ...diagnostic };
}
}
+79 -8
View File
@@ -82,6 +82,10 @@ function sanitizeMetadata(
export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name);
private s3Client: S3Client;
// Signs the URLs handed to clients. Same instance as `s3Client` unless
// `S3_PUBLIC_ENDPOINT` names a different, client-reachable address.
private presignClient: S3Client;
private publicEndpoint: string;
private bucket: string;
// Upper bound on presign batch array length (DoS guard).
private static readonly MAX_BATCH_ITEMS = 1000;
@@ -112,16 +116,34 @@ export class SyncService implements OnModuleInit {
this.bucket = requireEnv("S3_BUCKET");
const credentials = { accessKeyId, secretAccessKey };
this.s3Client = new S3Client({
endpoint,
region,
credentials: {
accessKeyId,
secretAccessKey,
},
credentials,
forcePathStyle,
});
// Presigned URLs are handed to a desktop client on another machine, so they
// must name a host that client can reach. `S3_ENDPOINT` is often reachable
// only from the server: the documented compose file points it at
// `http://minio:9000`, a Docker service name that resolves on the compose
// network and nowhere else. Signing is bound to the host, so the presign
// client is a second client pinned to the public address rather than a
// string rewrite of the signed URL.
const publicEndpoint =
this.configService.get<string>("S3_PUBLIC_ENDPOINT") || endpoint;
this.publicEndpoint = publicEndpoint;
this.presignClient =
publicEndpoint === endpoint
? this.s3Client
: new S3Client({
endpoint: publicEndpoint,
region,
credentials,
forcePathStyle,
});
this.backendInternalUrl = this.configService.get<string>(
"BACKEND_INTERNAL_URL",
);
@@ -132,6 +154,51 @@ export class SyncService implements OnModuleInit {
async onModuleInit() {
await this.ensureBucketExists();
this.warnIfPresignEndpointIsServerOnly();
}
/**
* The address clients are sent to for object transfers, for `/readyz` to
* report when a self-hoster is debugging a failing sync.
*
* Withheld in cloud mode: `/readyz` is unauthenticated, and a managed
* deployment should not publish its storage host to anyone who can reach the
* probe. Self-hosters own both ends, and the value is the whole point of the
* diagnostic there.
*/
getDiagnosticStorageEndpoint(): string | undefined {
const isCloud = Boolean(
this.configService.get<string>("SYNC_JWT_PUBLIC_KEY"),
);
return isCloud ? undefined : this.publicEndpoint;
}
/**
* A single-label host (`minio`, `s3`) only resolves inside the container
* network, so every presigned URL built from it is unreachable for the
* desktop client even though the server's own S3 calls succeed. That failure
* shows up as healthy `/health` and `/readyz` with every file transfer
* failing at connect, which is near-impossible to diagnose from the client.
* Say it once at boot instead.
*/
private warnIfPresignEndpointIsServerOnly(): void {
let host: string;
try {
host = new URL(this.publicEndpoint).hostname;
} catch {
return;
}
const isSingleLabel =
!host.includes(".") && !host.includes(":") && host !== "localhost";
if (!isSingleLabel) return;
this.logger.warn(
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
"to an address your devices can reach (and publish that port).",
);
}
private async ensureBucketExists(): Promise<void> {
@@ -332,7 +399,7 @@ export class SyncService implements OnModuleInit {
const metadataHeaders = new Set(
Object.keys(metadata ?? {}).map((name) => `x-amz-meta-${name}`),
);
const url = await getSignedUrl(this.s3Client, command, {
const url = await getSignedUrl(this.presignClient, command, {
expiresIn,
// The AWS presigner otherwise hoists user metadata into the query string.
// The client echoes the response metadata as headers, so those headers
@@ -374,7 +441,7 @@ export class SyncService implements OnModuleInit {
Key: key,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
const url = await getSignedUrl(this.presignClient, command, { expiresIn });
return {
url,
@@ -505,7 +572,9 @@ export class SyncService implements OnModuleInit {
ContentType: item.contentType || "application/octet-stream",
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
const url = await getSignedUrl(this.presignClient, command, {
expiresIn,
});
return {
key: item.key,
@@ -565,7 +634,9 @@ export class SyncService implements OnModuleInit {
Key: key,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
const url = await getSignedUrl(this.presignClient, command, {
expiresIn,
});
return {
key: rawKey,
@@ -0,0 +1,201 @@
import { INestApplication } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
import { App } from "supertest/types";
import { AppController } from "./../src/app.controller.js";
import { AppService } from "./../src/app.service.js";
import { SyncModule } from "./../src/sync/sync.module.js";
import {
configureTestEnv,
TEST_S3_ENDPOINT,
TEST_SYNC_TOKEN,
waitForTestS3,
} from "./test-env.js";
// Presigning is offline, so this host never has to accept a connection — the
// assertions are about which host ends up in the signed URL.
const PUBLIC_ENDPOINT = "https://storage.example.com";
// Only needs to be present for the server to consider itself cloud-mode; no
// token is verified against it in these assertions.
const CLOUD_PUBLIC_KEY =
"-----BEGIN PUBLIC KEY-----\nnot-a-real-key\n-----END PUBLIC KEY-----";
interface PresignResponse {
url: string;
}
interface PresignBatchResponse {
items: Array<{ key: string; url: string }>;
}
interface ReadyResponse {
status: string;
s3: boolean;
storageEndpoint: string;
}
async function bootstrap(publicEndpoint: string | undefined) {
configureTestEnv();
if (publicEndpoint) {
process.env.S3_PUBLIC_ENDPOINT = publicEndpoint;
} else {
delete process.env.S3_PUBLIC_ENDPOINT;
}
await waitForTestS3();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true }), SyncModule],
controllers: [AppController],
providers: [AppService],
}).compile();
const app = moduleFixture.createNestApplication<INestApplication<App>>();
await app.listen(0);
return app;
}
// A self-hosted server usually reaches its storage over a private address the
// desktop client has no route to. Signing client URLs against that address
// handed every client a URL it could not open, so uploads failed at connect
// while /health and /readyz stayed green.
describe("presigned URL host", () => {
describe("with S3_PUBLIC_ENDPOINT set", () => {
let app: INestApplication<App>;
beforeAll(async () => {
app = await bootstrap(PUBLIC_ENDPOINT);
});
afterAll(async () => {
delete process.env.S3_PUBLIC_ENDPOINT;
await app.close();
});
it("signs single upload URLs against the public endpoint", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-upload")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "endpoint/single.txt" })
.expect(200);
const { url } = response.body as PresignResponse;
expect(url.startsWith(PUBLIC_ENDPOINT)).toBe(true);
expect(url).not.toContain(TEST_S3_ENDPOINT);
});
it("signs batch upload URLs against the public endpoint", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-upload-batch")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ items: [{ key: "endpoint/a.txt" }, { key: "endpoint/b.txt" }] })
.expect(200);
const { items } = response.body as PresignBatchResponse;
expect(items).toHaveLength(2);
for (const item of items) {
expect(item.url.startsWith(PUBLIC_ENDPOINT)).toBe(true);
}
});
it("signs download URLs against the public endpoint", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-download")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "endpoint/single.txt" })
.expect(200);
const { url } = response.body as PresignResponse;
expect(url.startsWith(PUBLIC_ENDPOINT)).toBe(true);
});
// The server's own S3 calls must keep using the private endpoint, or
// pointing clients at a public address would break the server itself.
it("still reaches storage over the private endpoint", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "endpoint/does-not-exist" })
.expect(200);
expect(response.body).toEqual({ exists: false });
});
it("reports the client-facing endpoint from /readyz", async () => {
const response = await request(app.getHttpServer())
.get("/readyz")
.expect(200);
const body = response.body as ReadyResponse;
expect(body.s3).toBe(true);
expect(body.storageEndpoint).toBe(PUBLIC_ENDPOINT);
});
});
// /readyz has no auth, so a managed deployment must not publish its storage
// host to anyone who can reach the probe.
describe("in cloud mode", () => {
let app: INestApplication<App>;
const previousKey = process.env.SYNC_JWT_PUBLIC_KEY;
beforeAll(async () => {
process.env.SYNC_JWT_PUBLIC_KEY = CLOUD_PUBLIC_KEY;
app = await bootstrap(PUBLIC_ENDPOINT);
});
afterAll(async () => {
if (previousKey === undefined) {
delete process.env.SYNC_JWT_PUBLIC_KEY;
} else {
process.env.SYNC_JWT_PUBLIC_KEY = previousKey;
}
delete process.env.S3_PUBLIC_ENDPOINT;
await app.close();
});
it("withholds the storage endpoint from /readyz", async () => {
const response = await request(app.getHttpServer())
.get("/readyz")
.expect(200);
const body = response.body as ReadyResponse;
expect(body.s3).toBe(true);
expect(body.storageEndpoint).toBeUndefined();
expect(JSON.stringify(body)).not.toContain("storage.example.com");
});
});
describe("without S3_PUBLIC_ENDPOINT", () => {
let app: INestApplication<App>;
beforeAll(async () => {
app = await bootstrap(undefined);
});
afterAll(async () => {
await app.close();
});
it("falls back to S3_ENDPOINT", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-upload")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "endpoint/fallback.txt" })
.expect(200);
const { url } = response.body as PresignResponse;
expect(url.startsWith(TEST_S3_ENDPOINT)).toBe(true);
});
it("reports the fallback endpoint from /readyz", async () => {
const response = await request(app.getHttpServer())
.get("/readyz")
.expect(200);
expect((response.body as ReadyResponse).storageEndpoint).toBe(
TEST_S3_ENDPOINT,
);
});
});
});
+79 -3
View File
@@ -680,6 +680,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
@@ -967,6 +976,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
@@ -1785,7 +1803,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.2"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -1797,7 +1815,7 @@ dependencies = [
"blake3",
"boringtun",
"bzip2",
"cbc",
"cbc 0.2.1",
"chrono",
"chrono-tz",
"clap",
@@ -1809,6 +1827,7 @@ dependencies = [
"flate2",
"futures-util",
"globset",
"gtk",
"http-body-util",
"hyper",
"hyper-util",
@@ -1831,6 +1850,8 @@ dependencies = [
"resvg",
"ring",
"rusqlite",
"secret-service",
"security-framework",
"serde",
"serde_json",
"serde_yaml",
@@ -3378,6 +3399,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array",
]
@@ -3387,7 +3409,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding",
"block-padding 0.4.2",
"hybrid-array",
]
@@ -4084,6 +4106,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -4094,6 +4130,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -4120,6 +4165,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -5712,6 +5767,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2 0.10.9",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -7197,6 +7271,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -8984,6 +9059,7 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
+9 -1
View File
@@ -25,6 +25,7 @@ export const commandCoverage = {
"get_system_info",
"dismiss_window_resize_warning",
"get_window_resize_warning_dismissed",
"window_decorations::get_window_decoration_layout",
"get_onboarding_completed",
"complete_onboarding",
],
@@ -71,6 +72,7 @@ export const commandCoverage = {
"update_stored_proxy",
"delete_stored_proxy",
"check_proxy_validity",
"validate_vless_uri",
"get_cached_proxy_check",
"export_proxies",
"import_proxies_json",
@@ -174,8 +176,9 @@ export const commandCoverage = {
"generate_sample_fingerprint",
"is_geoip_database_available",
"download_geoip_database",
"fingerprint_consistency::check_profile_fingerprint_consistency",
"fingerprint_consistency::match_profile_fingerprint_to_exit",
"launch_gate::get_profile_pre_launch_checks",
"launch_gate::ack_launch_gate",
"check_wayfern_terms_accepted",
"check_wayfern_downloaded",
"accept_wayfern_terms",
@@ -255,6 +258,7 @@ export const commandCoverage = {
"list_remote_sessions",
"get_remote_session",
"stop_remote_session",
"get_remote_handoff_states",
"start_remote_session_events",
"stop_remote_session_events",
"get_remote_session_events_status",
@@ -275,6 +279,10 @@ export const commandCoverage = {
"get_cookie_bot_presets",
"get_remote_hours_quota",
"get_cookie_bot_usage",
"cookie_bot::get_cookie_bot_user_templates",
"cookie_bot::create_cookie_bot_user_template",
"cookie_bot::update_cookie_bot_user_template",
"cookie_bot::delete_cookie_bot_user_template",
],
},
updateContracts: {
+51
View File
@@ -68,6 +68,7 @@ export class AppSession {
extraEnv = {},
args = [],
seedVersionCache = true,
seedDownloadedBrowser = false,
onboardingCompleted = true,
wayfernTermsAccepted = true,
}) {
@@ -80,6 +81,7 @@ export class AppSession {
this.extraEnv = extraEnv;
this.args = args;
this.seedVersionCache = seedVersionCache;
this.seedDownloadedBrowser = seedDownloadedBrowser;
this.onboardingCompleted = onboardingCompleted;
this.wayfernTermsAccepted = wayfernTermsAccepted;
this.session = null;
@@ -184,6 +186,54 @@ export class AppSession {
}
});
}
if (this.seedDownloadedBrowser) {
// Registers a Wayfern version as "downloaded" without installing a
// binary. Profile import derives its version from this registry and
// fails with BROWSER_NOT_DOWNLOADED otherwise, so suites that exercise
// import but never launch a browser need the entry and nothing else.
const seededVersion =
typeof this.seedDownloadedBrowser === "string"
? this.seedDownloadedBrowser
: "150.0.7871.100";
const installDir = path.join(
this.dataRoot,
"data",
"binaries",
"wayfern",
seededVersion,
);
await mkdir(installDir, { recursive: true });
const registryPath = path.join(
this.dataRoot,
"data",
"data",
"downloaded_browsers.json",
);
await mkdir(path.dirname(registryPath), { recursive: true });
await writeFile(
registryPath,
`${JSON.stringify(
{
browsers: {
wayfern: {
[seededVersion]: {
browser: "wayfern",
version: seededVersion,
file_path: installDir,
},
},
},
},
null,
2,
)}\n`,
{ flag: "wx" },
).catch((error) => {
if (error.code !== "EEXIST") {
throw error;
}
});
}
const env = isolatedEnvironment(this.root, {
DONUT_E2E_DISABLE_STARTUP_NETWORK: "1",
...(process.env.DONUT_E2E_FIXTURE_URL
@@ -525,6 +575,7 @@ export function appFromEnvironment(name, options = {}) {
extraEnv: options.extraEnv,
args: options.args,
seedVersionCache: options.seedVersionCache,
seedDownloadedBrowser: options.seedDownloadedBrowser,
onboardingCompleted: options.onboardingCompleted,
wayfernTermsAccepted: options.wayfernTermsAccepted,
});
+99
View File
@@ -12,6 +12,7 @@ import {
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
export const TEST_BROWSER_VERSION = "150.0.7871.100";
@@ -220,3 +221,101 @@ export function currentHostOs() {
? "windows"
: "linux";
}
/**
* Write a Chromium cookie store at schema version 24 with plaintext values.
*
* Plaintext is deliberate: it is what a store looks like when the source
* browser could not reach its keyring, and it lets the suite assert that
* import seals every row with the target profile's key. Chromium reads a row
* whose `encrypted_value` is empty, and drops any row where both columns are
* set, so "value cleared and encrypted_value populated" is the only shape that
* actually loads.
*/
export function writeChromiumCookies(dbPath, cookies) {
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE cookies(
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
encrypted_value BLOB NOT NULL DEFAULT '',
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL,
last_access_utc INTEGER NOT NULL,
has_expires INTEGER NOT NULL DEFAULT 1,
is_persistent INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1,
samesite INTEGER NOT NULL DEFAULT -1,
source_scheme INTEGER NOT NULL DEFAULT 0,
source_port INTEGER NOT NULL DEFAULT -1,
last_update_utc INTEGER NOT NULL DEFAULT 0,
source_type INTEGER NOT NULL DEFAULT 0,
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX cookies_unique_index
ON cookies(host_key, top_frame_site_key, name, path);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
INSERT INTO meta VALUES('version', '24');
INSERT INTO meta VALUES('last_compatible_version', '24');
`);
const insert = db.prepare(
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
VALUES(?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0)`,
);
// `encrypted` cookies are written the way Chromium's v23->v24 migration
// does: BindString into a BLOB column, which leaves the storage class as
// TEXT. Reading that as a strict blob returns empty and silently blanks the
// cookie, so the suite has to reproduce it rather than only binding blobs.
const insertAsText = db.prepare(
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
VALUES(?, ?, '', ?, '', CAST(? AS TEXT), '/', 0, 0, 0, 0)`,
);
let creation = 13000000000000000;
for (const cookie of cookies) {
if (cookie.encryptedValueText === undefined) {
insert.run(creation++, cookie.host, cookie.name, cookie.value, "");
} else {
insertAsText.run(
creation++,
cookie.host,
cookie.name,
cookie.encryptedValueText,
);
}
}
db.close();
}
/** Write a Chromium History database holding the given URLs. */
export function writeChromiumHistory(dbPath, urls) {
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE urls(
id INTEGER PRIMARY KEY AUTOINCREMENT,
url LONGVARCHAR,
title LONGVARCHAR,
visit_count INTEGER DEFAULT 0 NOT NULL,
typed_count INTEGER DEFAULT 0 NOT NULL,
last_visit_time INTEGER NOT NULL,
hidden INTEGER DEFAULT 0 NOT NULL
);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
INSERT INTO meta VALUES('version', '69');
INSERT INTO meta VALUES('last_compatible_version', '16');
`);
const insert = db.prepare(
"INSERT INTO urls(url, title, visit_count, typed_count, last_visit_time, hidden) VALUES(?, ?, 1, 0, ?, 0)",
);
let visit = 13000000000000000;
for (const url of urls) {
insert.run(url, url, visit++);
}
db.close();
}
+123 -7
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises";
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
@@ -244,13 +244,129 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
profileId: profile.id,
exitIp: "8.8.8.8",
});
const consistency = await app.invoke(
"check_profile_fingerprint_consistency",
{
profileId: profile.id,
},
// Pre-launch gate: local-only checks that must answer without starting a
// proxy, an Xray worker or the browser.
const checks = await app.invoke("get_profile_pre_launch_checks", {
profileId: profile.id,
});
assert.ok(Array.isArray(checks.vpn_extensions));
assert.equal(
typeof checks.scan_state,
"string",
"the scan must report whether it saw the whole profile",
);
assert.equal(typeof checks.consistency, "object");
assert.equal(typeof checks.exit_probe_pending, "boolean");
assert.equal(typeof checks.exit_measurement_unreliable, "boolean");
// This profile has no VPN extension, so nothing may block its launch.
assert.equal(
checks.vpn_extensions.length,
0,
"a clean profile must not report a VPN extension",
);
assert.equal(
checks.consent_token,
null,
"a consent token is only minted when a cached mismatch is blocking",
);
// Extension detection, against manifests written where Chromium puts
// them. The three cases are the whole point of the classifier: a real VPN
// is named as one, a known VPN with an unrevealing name is caught by its
// id, and a download manager holding the same `proxy` permission is
// reported as a capability and never as a VPN.
// `DONUTBROWSER_DATA_ROOT` puts the data dir at <dataRoot>/data, so this
// is app_dirs::profiles_dir() plus the layout Chromium itself uses.
const extensionsDir = path.join(
app.dataRoot,
"data",
"profiles",
profile.id,
"profile",
"Default",
"Extensions",
);
const seedExtension = async (id, version, manifest) => {
const dir = path.join(extensionsDir, id, `${version}_0`);
await mkdir(dir, { recursive: true });
await writeFile(
path.join(dir, "manifest.json"),
JSON.stringify(manifest),
);
};
const IDM_ID = "ngpampappnmepgilojfohadhhmbhlaek";
const HOTSPOT_SHIELD_ID = "nlbejmccbhkncgokjcmghpfloaajcffj";
const NAMED_VPN_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
await seedExtension(IDM_ID, "6.43.1", {
name: "IDM Integration Module",
version: "6.43.1",
description: "Download files with Internet Download Manager",
permissions: ["downloads", "storage", "proxy", "nativeMessaging"],
});
await seedExtension(HOTSPOT_SHIELD_ID, "10.0.0", {
name: "Hotspot Shield",
version: "10.0.0",
permissions: ["proxy"],
});
await seedExtension(NAMED_VPN_ID, "1.0.0", {
name: "Turbo VPN Free",
version: "1.0.0",
permissions: ["proxy"],
});
const withExtensions = await app.invoke("get_profile_pre_launch_checks", {
profileId: profile.id,
});
const detected = new Map(
withExtensions.vpn_extensions.map((item) => [item.key, item]),
);
assert.equal(detected.size, 3, "every seeded extension must be reported");
assert.equal(detected.get(`crx:${NAMED_VPN_ID}`).confidence, "confirmed");
assert.equal(
detected.get(`crx:${HOTSPOT_SHIELD_ID}`).confidence,
"confirmed",
"a known VPN id must be named even when its name gives nothing away",
);
assert.equal(
detected.get(`crx:${IDM_ID}`).confidence,
"capability",
"a download manager holding the proxy permission is not a VPN",
);
assert.ok(
detected.get(`crx:${IDM_ID}`).proxy_control,
"it does still hold the permission, which is why it is listed at all",
);
assert.equal(
withExtensions.exit_measurement_unreliable,
true,
"a proxy-capable extension makes the exit measurement a caveat",
);
// Acknowledgements are per-profile and must be accepted for both kinds.
await app.invoke("ack_launch_gate", {
profileId: profile.id,
ackFingerprint: false,
ackExtensionKeys: ["crx:e2e-nonexistent-extension"],
});
await app.invoke("ack_launch_gate", {
profileId: profile.id,
ackFingerprint: true,
ackExtensionKeys: [`crx:${IDM_ID}`],
});
const afterAck = await app.invoke("get_profile_pre_launch_checks", {
profileId: profile.id,
});
assert.deepEqual(
afterAck.vpn_extensions.map((item) => item.key).sort(),
[`crx:${HOTSPOT_SHIELD_ID}`, `crx:${NAMED_VPN_ID}`].sort(),
"an acknowledged extension stops being reported, the others do not",
);
assert.match(
await app.invokeError("get_profile_pre_launch_checks", {
profileId: "00000000-0000-0000-0000-000000000000",
}),
/PROFILE_NOT_FOUND/,
);
assert.equal(typeof consistency, "object");
const directProfile = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
+458 -212
View File
@@ -1,9 +1,16 @@
import assert from "node:assert/strict";
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import test from "node:test";
import { withApp } from "../lib/app.mjs";
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
import {
extensionZipBase64,
wireGuardFixture,
writeChromiumCookies,
writeChromiumHistory,
} from "../lib/fixtures.mjs";
async function createProfile(app, name = "Entity Profile") {
return app.invoke("create_browser_profile_new", {
@@ -24,229 +31,468 @@ async function createProfile(app, name = "Entity Profile") {
}
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
await withApp("entities-core", async (app) => {
const group = await app.invoke("create_profile_group", {
name: "Research",
});
assert.equal(group.name, "Research");
const renamedGroup = await app.invoke("update_profile_group", {
groupId: group.id,
name: "Research Team",
});
assert.equal(renamedGroup.name, "Research Team");
// Profile import derives its browser version from the downloaded-browsers
// registry, so without an entry every import fails with
// BROWSER_NOT_DOWNLOADED before it touches a single file.
await withApp(
"entities-core",
async (app) => {
const group = await app.invoke("create_profile_group", {
name: "Research",
});
assert.equal(group.name, "Research");
const renamedGroup = await app.invoke("update_profile_group", {
groupId: group.id,
name: "Research Team",
});
assert.equal(renamedGroup.name, "Research Team");
const duplicateError = await app.invokeError("create_profile_group", {
name: "Research Team",
});
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
const duplicateError = await app.invokeError("create_profile_group", {
name: "Research Team",
});
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
const proxy = await app.invoke("create_stored_proxy", {
name: "Local Dead Proxy",
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9,
username: "e2e-user",
password: "e2e-pass",
},
});
assert.equal(proxy.proxy_settings.password, "e2e-pass");
const updatedProxy = await app.invoke("update_stored_proxy", {
proxyId: proxy.id,
name: "Updated Proxy",
proxySettings: {
proxy_type: "socks5",
host: "127.0.0.1",
port: 9,
username: null,
password: null,
},
});
assert.equal(updatedProxy.name, "Updated Proxy");
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
const proxy = await app.invoke("create_stored_proxy", {
name: "Local Dead Proxy",
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9,
username: "e2e-user",
password: "e2e-pass",
},
});
assert.equal(proxy.proxy_settings.password, "e2e-pass");
const updatedProxy = await app.invoke("update_stored_proxy", {
proxyId: proxy.id,
name: "Updated Proxy",
proxySettings: {
proxy_type: "socks5",
host: "127.0.0.1",
port: 9,
username: null,
password: null,
},
});
assert.equal(updatedProxy.name, "Updated Proxy");
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
const parsed = await app.invoke("parse_txt_proxies", {
content: [
"http://one.example:8080",
"two.example:1080:user:pass",
"not a proxy",
].join("\n"),
});
assert.equal(parsed.length, 3);
assert.ok(parsed.some((result) => result.status === "parsed"));
assert.ok(parsed.some((result) => result.status === "invalid"));
const parsedProxy = parsed.find((result) => result.status === "parsed");
const { status: _status, ...parsedProxyFields } = parsedProxy;
const parsedImport = await app.invoke("import_proxies_from_parsed", {
parsedProxies: [parsedProxyFields],
namePrefix: "Parsed",
});
assert.equal(parsedImport.imported_count, 1);
const parsed = await app.invoke("parse_txt_proxies", {
content: [
"http://one.example:8080",
"two.example:1080:user:pass",
"not a proxy",
].join("\n"),
});
assert.equal(parsed.length, 3);
assert.ok(parsed.some((result) => result.status === "parsed"));
assert.ok(parsed.some((result) => result.status === "invalid"));
const parsedProxy = parsed.find((result) => result.status === "parsed");
const { status: _status, ...parsedProxyFields } = parsedProxy;
const parsedImport = await app.invoke("import_proxies_from_parsed", {
parsedProxies: [parsedProxyFields],
namePrefix: "Parsed",
});
assert.equal(parsedImport.imported_count, 1);
const validityError = await app.invokeError("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: null,
});
assert.match(validityError, /Proxy check failed|Could not connect/i);
const cachedValidity = await app.invoke("get_cached_proxy_check", {
proxyId: proxy.id,
});
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
const validityError = await app.invokeError("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: null,
});
assert.match(validityError, /Proxy check failed|Could not connect/i);
const cachedValidity = await app.invoke("get_cached_proxy_check", {
proxyId: proxy.id,
});
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
const exported = JSON.parse(
await app.invoke("export_proxies", { format: "json" }),
);
assert.equal(exported.proxies.length, 2);
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
assert.ok(exported.proxies.some((item) => item.name === "Parsed Proxy 1"));
const importResult = await app.invoke("import_proxies_json", {
content: JSON.stringify({
version: "1",
source: "Donut Browser",
exported_at: new Date().toISOString(),
proxies: [
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
// uses this to tell the user WHICH part of their setup is unsupported
// instead of implying they mistyped, so the reason must survive the IPC hop.
const goodVless =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" +
"?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" +
"&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome";
assert.equal(
await app.invoke("validate_vless_uri", { uri: goodVless }),
null,
);
for (const [uri, reason] of [
[goodVless.replace("security=reality", "security=tls"), "security"],
[goodVless.replace("type=tcp", "type=ws"), "transport"],
[goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
]) {
// invokeError returns the command's error wrapped in a message, so match
// rather than JSON.parse the whole string.
const error = await app.invokeError("validate_vless_uri", { uri });
assert.match(error, /VLESS_CONFIG_INVALID/);
assert.match(
error,
new RegExp(`"reason":"${reason}"`),
`expected reason ${reason} for ${uri}, got: ${error}`,
);
}
const exported = JSON.parse(
await app.invoke("export_proxies", { format: "json" }),
);
assert.equal(exported.proxies.length, 2);
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
assert.ok(
exported.proxies.some((item) => item.name === "Parsed Proxy 1"),
);
const importResult = await app.invoke("import_proxies_json", {
content: JSON.stringify({
version: "1",
source: "Donut Browser",
exported_at: new Date().toISOString(),
proxies: [
{
name: "Imported Proxy",
type: "http",
host: "127.0.0.1",
port: 8081,
},
],
}),
});
assert.equal(importResult.imported_count, 1);
const profile = await createProfile(app);
assert.equal(profile.name, "Entity Profile");
assert.equal(
(
await app.invoke("update_profile_proxy", {
profileId: profile.id,
proxyId: proxy.id,
})
).proxy_id,
proxy.id,
);
await app.invoke("assign_profiles_to_group", {
profileIds: [profile.id],
groupId: group.id,
});
await app.invoke("rename_profile", {
profileId: profile.id,
newName: "Renamed Profile",
});
await app.invoke("update_profile_tags", {
profileId: profile.id,
tags: ["alpha", "automation"],
});
await app.invoke("update_profile_note", {
profileId: profile.id,
note: "Extensive E2E metadata",
});
await app.invoke("update_profile_window_color", {
profileId: profile.id,
windowColor: "#123456",
});
await app.invoke("update_profile_launch_hook", {
profileId: profile.id,
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
});
const invalidHook = await app.invokeError("update_profile_launch_hook", {
profileId: profile.id,
launchHook: "file:///etc/passwd",
});
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
await app.invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
rules: ["localhost", "*.internal.example"],
});
await app.invoke("update_profile_dns_blocklist", {
profileId: profile.id,
dnsBlocklist: "light",
});
await app.invoke("update_profile_clear_on_close", {
profileId: profile.id,
clearOnClose: true,
});
const profiles = await app.invoke("list_browser_profiles");
const changed = profiles.find((item) => item.id === profile.id);
assert.deepEqual(changed.tags, ["alpha", "automation"]);
assert.equal(changed.note, "Extensive E2E metadata");
assert.equal(changed.window_color, "#123456");
assert.equal(changed.group_id, group.id);
assert.deepEqual(changed.proxy_bypass_rules, [
"localhost",
"*.internal.example",
]);
assert.equal(changed.dns_blocklist, "light");
assert.equal(changed.clear_on_close, true);
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
"alpha",
"automation",
]);
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
const importRoot = path.join(app.root, "profile-import-fixture");
const importProfile = path.join(importRoot, "Default");
await mkdir(importProfile, { recursive: true });
await writeFile(
path.join(importProfile, "Preferences"),
JSON.stringify({
profile: { name: "Imported fixture", exit_type: "Crashed" },
download: { default_directory: "/Users/someone-else/Downloads" },
}),
);
// A Secure Preferences with MACs that can never validate under Wayfern,
// one real (relative-path) extension and one component extension that
// belongs to the source browser's bundle.
await writeFile(
path.join(importProfile, "Secure Preferences"),
JSON.stringify({
protection: { super_mac: "deadbeef", macs: { extensions: {} } },
extensions: {
settings: {
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {
path: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0",
},
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: {
path: "/Applications/Chromium.app/Contents/Resources/component",
},
},
},
}),
);
// Caches must not be copied, and site data must be.
await mkdir(path.join(importProfile, "Cache"), { recursive: true });
await writeFile(path.join(importProfile, "Cache", "data_0"), "junk");
await mkdir(path.join(importProfile, "Local Storage", "leveldb"), {
recursive: true,
});
await writeFile(
path.join(importProfile, "Local Storage", "leveldb", "000003.log"),
"site-data",
);
writeChromiumHistory(path.join(importProfile, "History"), [
"https://example.com/",
"https://example.org/",
]);
writeChromiumCookies(path.join(importProfile, "Cookies"), [
{ host: "example.com", name: "sid", value: "session-token" },
{ host: "example.org", name: "pref", value: "dark" },
// Sealed with a key this machine does not have, and stored the way
// Chromium's own v23->v24 migration stores it (TEXT in a BLOB column).
// It must be reported as unrecoverable, never silently blanked and
// counted as migrated.
{
host: "sealed.example",
name: "sid",
encryptedValueText: "v10\u0001\u0002\u0003unopenable-ciphertext",
},
]);
const scanned = await app.invoke("scan_folder_for_profiles", {
folderPath: importRoot,
});
assert.equal(scanned.length, 1);
assert.equal(scanned[0].mapped_browser, "wayfern");
const importBatch = await app.invoke("import_browser_profiles", {
items: [
{
name: "Imported Proxy",
type: "http",
host: "127.0.0.1",
port: 8081,
source_path: scanned[0].path,
browser_type: scanned[0].browser,
new_profile_name: "Imported Profile",
proxy_id: null,
vpn_id: null,
},
],
}),
});
assert.equal(importResult.imported_count, 1);
groupId: null,
duplicateStrategy: "rename",
// A stored fingerprint, as elsewhere in this suite: generating a real
// one shells out to the Wayfern binary, which no CRUD suite installs.
wayfernConfig: { fingerprint: "{}" },
});
assert.equal(
importBatch.imported_count,
1,
`import must succeed: ${JSON.stringify(importBatch.results)}`,
);
const profile = await createProfile(app);
assert.equal(profile.name, "Entity Profile");
assert.equal(
(
await app.invoke("update_profile_proxy", {
profileId: profile.id,
proxyId: proxy.id,
})
).proxy_id,
proxy.id,
);
await app.invoke("assign_profiles_to_group", {
profileIds: [profile.id],
groupId: group.id,
});
await app.invoke("rename_profile", {
profileId: profile.id,
newName: "Renamed Profile",
});
await app.invoke("update_profile_tags", {
profileId: profile.id,
tags: ["alpha", "automation"],
});
await app.invoke("update_profile_note", {
profileId: profile.id,
note: "Extensive E2E metadata",
});
await app.invoke("update_profile_window_color", {
profileId: profile.id,
windowColor: "#123456",
});
await app.invoke("update_profile_launch_hook", {
profileId: profile.id,
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
});
const invalidHook = await app.invokeError("update_profile_launch_hook", {
profileId: profile.id,
launchHook: "file:///etc/passwd",
});
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
await app.invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
rules: ["localhost", "*.internal.example"],
});
await app.invoke("update_profile_dns_blocklist", {
profileId: profile.id,
dnsBlocklist: "light",
});
await app.invoke("update_profile_clear_on_close", {
profileId: profile.id,
clearOnClose: true,
});
const imported = importBatch.results[0];
// The assertion whose absence let the layout bug ship: an import that
// carries nothing used to be indistinguishable from a successful one.
assert.ok(
imported.report,
"an imported profile must report what it carried",
);
assert.equal(imported.report.cookies_migrated, 2);
assert.equal(
imported.report.cookies_unrecoverable,
1,
"a cookie no key can open must be counted, not silently emptied",
);
assert.equal(imported.report.history_entries, 2);
assert.equal(imported.report.extensions_migrated, 1);
assert.ok(imported.report.local_storage_origins > 0);
const profiles = await app.invoke("list_browser_profiles");
const changed = profiles.find((item) => item.id === profile.id);
assert.deepEqual(changed.tags, ["alpha", "automation"]);
assert.equal(changed.note, "Extensive E2E metadata");
assert.equal(changed.window_color, "#123456");
assert.equal(changed.group_id, group.id);
assert.deepEqual(changed.proxy_bypass_rules, [
"localhost",
"*.internal.example",
]);
assert.equal(changed.dns_blocklist, "light");
assert.equal(changed.clear_on_close, true);
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
"alpha",
"automation",
]);
const importedDir = path.join(
app.dataRoot,
"data",
"profiles",
imported.profile_id,
"profile",
);
// Chromium reads <user-data-dir>/Default/, so anything at the root is
// invisible to the browser no matter how faithfully it was copied.
assert.ok(
existsSync(path.join(importedDir, "Default", "Preferences")),
"profile content must land under Default/",
);
assert.ok(
!existsSync(path.join(importedDir, "Preferences")),
"nothing profile-scoped may sit at the user-data-dir root",
);
assert.ok(
existsSync(path.join(importedDir, "os_crypt_key")),
"Wayfern reads its key from the user-data-dir root",
);
assert.ok(
!existsSync(path.join(importedDir, "Default", "Cache")),
"caches are pure waste and must not be copied",
);
assert.ok(
existsSync(
path.join(
importedDir,
"Default",
"Local Storage",
"leveldb",
"000003.log",
),
),
"site data must survive",
);
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
const importRoot = path.join(app.root, "profile-import-fixture");
const importProfile = path.join(importRoot, "Default");
await mkdir(importProfile, { recursive: true });
await writeFile(
path.join(importProfile, "Preferences"),
JSON.stringify({ profile: { name: "Imported fixture" } }),
);
const scanned = await app.invoke("scan_folder_for_profiles", {
folderPath: importRoot,
});
assert.equal(scanned.length, 1);
assert.equal(scanned[0].mapped_browser, "wayfern");
const importBatch = await app.invoke("import_browser_profiles", {
items: [
{
source_path: scanned[0].path,
browser_type: scanned[0].browser,
new_profile_name: "Imported Profile",
proxy_id: null,
vpn_id: null,
},
],
groupId: null,
duplicateStrategy: "rename",
wayfernConfig: null,
});
assert.equal(importBatch.imported_count + importBatch.failed_count, 1);
const archivePath = path.join(app.root, "profile-import-fixture.zip");
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
const archiveScan = await app.invoke("scan_profile_archive", {
archivePath,
});
assert.ok(Array.isArray(archiveScan.profiles));
await app.invoke("cleanup_profile_import_scratch", {
extractedDir: archiveScan.extracted_dir,
});
const importedCookies = path.join(
importedDir,
"Default",
process.platform === "win32"
? path.join("Network", "Cookies")
: "Cookies",
);
assert.ok(
existsSync(importedCookies),
"cookies must sit where this platform's Chromium reads them",
);
// Chromium drops any row where both value and encrypted_value are set, so
// a "migrated" cookie that kept its plaintext would never load.
const cookieDb = new DatabaseSync(importedCookies, { readOnly: true });
const rows = cookieDb
.prepare(
"SELECT host_key, value, length(encrypted_value) AS enc FROM cookies ORDER BY host_key",
)
.all();
cookieDb.close();
assert.equal(
rows.length,
2,
"the unrecoverable row is dropped, not kept empty",
);
for (const row of rows) {
assert.equal(row.value, "", `${row.host_key} kept a plaintext value`);
assert.ok(row.enc > 0, `${row.host_key} was not re-encrypted`);
}
const clone = await app.invoke("clone_profile", {
profileId: profile.id,
name: "Cloned Profile",
});
assert.notEqual(clone.id, profile.id);
assert.equal(clone.name, "Cloned Profile");
const counts = await app.invoke("get_groups_with_profile_counts");
assert.equal(counts.find((item) => item.id === group.id).count, 2);
assert.equal((await app.invoke("get_profile_groups")).length, 1);
const securePrefs = JSON.parse(
await readFile(
path.join(importedDir, "Default", "Secure Preferences"),
"utf8",
),
);
assert.equal(
securePrefs.protection,
undefined,
"MACs from another machine can never validate and must be stripped",
);
assert.ok(
securePrefs.extensions.settings.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
"the user's own extension must survive",
);
assert.equal(
securePrefs.extensions.settings.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,
undefined,
"a component extension pointing into the source browser must be dropped",
);
await app.invoke("delete_selected_profiles", {
profileIds: [profile.id, clone.id],
});
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
await app.invoke("delete_profile_group", { groupId: group.id });
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
for (const importedProxy of (await app.invoke("get_stored_proxies")).filter(
(item) =>
item.name === "Imported Proxy" || item.name.startsWith("Parsed Proxy"),
)) {
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
}
});
const prefs = JSON.parse(
await readFile(
path.join(importedDir, "Default", "Preferences"),
"utf8",
),
);
assert.equal(prefs.profile.exit_type, "Normal");
assert.equal(prefs.download.default_directory, undefined);
assert.equal(prefs.profile.name, "Imported fixture");
// A Gecko profile must say why it cannot be imported instead of silently
// producing an empty one.
const firefoxRoot = path.join(app.root, "firefox-profile-fixture");
await mkdir(firefoxRoot, { recursive: true });
await writeFile(path.join(firefoxRoot, "prefs.js"), "// prefs");
await writeFile(path.join(firefoxRoot, "places.sqlite"), "");
const geckoBatch = await app.invoke("import_browser_profiles", {
items: [
{
source_path: firefoxRoot,
browser_type: "firefox",
new_profile_name: "Gecko Profile",
proxy_id: null,
vpn_id: null,
},
],
groupId: null,
duplicateStrategy: "rename",
wayfernConfig: { fingerprint: "{}" },
});
assert.equal(geckoBatch.failed_count, 1);
assert.match(
geckoBatch.results[0].error,
/IMPORT_SOURCE_NOT_CHROMIUM/,
"a Firefox folder must be rejected by name, not imported empty",
);
const archivePath = path.join(app.root, "profile-import-fixture.zip");
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
const archiveScan = await app.invoke("scan_profile_archive", {
archivePath,
});
assert.ok(Array.isArray(archiveScan.profiles));
await app.invoke("cleanup_profile_import_scratch", {
extractedDir: archiveScan.extracted_dir,
});
const clone = await app.invoke("clone_profile", {
profileId: profile.id,
name: "Cloned Profile",
});
assert.notEqual(clone.id, profile.id);
assert.equal(clone.name, "Cloned Profile");
const counts = await app.invoke("get_groups_with_profile_counts");
assert.equal(counts.find((item) => item.id === group.id).count, 2);
assert.equal((await app.invoke("get_profile_groups")).length, 1);
await app.invoke("delete_selected_profiles", {
profileIds: [profile.id, clone.id, imported.profile_id],
});
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
await app.invoke("delete_profile_group", { groupId: group.id });
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
for (const importedProxy of (
await app.invoke("get_stored_proxies")
).filter(
(item) =>
item.name === "Imported Proxy" ||
item.name.startsWith("Parsed Proxy"),
)) {
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
}
},
{ seedDownloadedBrowser: true },
);
});
test("extensions, extension groups, VPN storage, DNS rules, and event-backed assignments", async () => {
+43
View File
@@ -316,6 +316,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
"update_proxy",
"get_page_content",
"get_interactive_elements",
// The remote loop has to be complete from MCP alone: start a session,
// watch it become usable, drive it with the interaction tools above, stop
// it. Any one of these missing leaves an agent able to lease a host it
// cannot use, or unable to lease one at all.
"run_profile_remote",
"get_remote_session",
"stop_remote_session",
]) {
assert.ok(names.includes(name), `MCP is missing ${name}`);
}
@@ -671,6 +678,15 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
}),
notSignedIn,
);
// The local-launch gate. Nothing has run remotely in this session, so it
// is empty — but it must answer, because a UI that cannot read it shows
// an enabled Run button over a profile the backend will refuse.
const handoff = await app.invoke("get_remote_handoff_states");
assert.ok(
handoff && typeof handoff === "object" && !Array.isArray(handoff),
"the handoff gate must answer with a profile-keyed object",
);
assert.equal(Object.keys(handoff).length, 0);
// The transition stream is what the desktop uses instead of polling, so
// its subscriber has to start, report itself, and stop on demand. Both
@@ -702,6 +718,33 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
}),
notSignedIn,
);
// Saved site lists are cloud-backed like the schedules above, so they
// must refuse the same way rather than appearing to work offline.
assert.match(
await app.invokeError("get_cookie_bot_user_templates", {}),
notSignedIn,
);
assert.match(
await app.invokeError("create_cookie_bot_user_template", {
name: "e2e list",
sites: ["example.com"],
}),
notSignedIn,
);
assert.match(
await app.invokeError("update_cookie_bot_user_template", {
id: "00000000-0000-0000-0000-000000000000",
name: "renamed",
sites: null,
}),
notSignedIn,
);
assert.match(
await app.invokeError("delete_cookie_bot_user_template", {
id: "00000000-0000-0000-0000-000000000000",
}),
notSignedIn,
);
assert.match(
await app.invokeError("check_cookie_bot_conflicts", {
profileId: missingProfileId,
+29 -1
View File
@@ -22,6 +22,30 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
true,
);
// Where the app draws its own titlebar it also owns the window controls,
// so it needs the desktop's button layout to know which side they go on.
const decorations = await app.invoke("get_window_decoration_layout");
assert.equal(typeof decorations?.client_side, "boolean");
if (decorations.client_side) {
// Only reported where decorations were actually dropped, which is
// every Linux session except KDE on Wayland.
assert.equal(process.platform, "linux");
// `layout` may be null when GtkSettings is unavailable; the frontend
// falls back to the default arrangement rather than drawing nothing,
// so asserting a string here would be stricter than the contract.
if (decorations.layout !== null) {
assert.equal(typeof decorations.layout, "string");
assert.match(
decorations.layout,
/close|minimize|maximize/,
`layout must name a drawable control, got: ${decorations.layout}`,
);
}
} else {
// The platform still draws a titlebar; the app must not draw a second.
assert.equal(decorations.layout, null);
}
const saved = await app.invoke("save_app_settings", {
settings: {
...initial,
@@ -113,8 +137,12 @@ test("keyboard command palette and major navigation surfaces are operable throug
assert.match(body, /Settings/i);
// Exercise native WebDriver element marshalling and click, not just script execution.
// Scoped to the open dialog on purpose: on Linux the app draws its own
// titlebar, whose "Close window" control appears earlier in the DOM, and
// clicking that would exercise the window lifecycle instead of the palette.
const close = await app.execute(
`return [...document.querySelectorAll("button")].find(
`const dialog = document.querySelector("[role='dialog']") ?? document;
return [...dialog.querySelectorAll("button")].find(
(button) => /close/i.test(button.getAttribute("aria-label") || button.textContent || "")
) ?? null;`,
);
+17 -1
View File
@@ -474,7 +474,7 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
await app.clickSelector('[aria-label="New proxy"]');
await app.waitForText("Add Proxy");
await app.fillSelector("#proxy-name", "E2E VLESS");
await chooseSelectOption(app, "#proxy-type", "VLESS · Vision · REALITY");
await chooseSelectOption(app, "#proxy-type", "VLESS");
assert.equal(
await app.execute(
@@ -512,6 +512,22 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
true,
);
// A well-formed URI for a setup Donut cannot use must say WHICH part is
// unsupported, rather than implying the user mistyped it.
await app.fillSelector(
"#proxy-vless-uri",
`${uri.replace("type=tcp", "type=ws")}&path=%2Fray`,
);
await app.waitFor(
() =>
app.execute(
`return /only|TCP|transport/i.test(
document.querySelector("#proxy-vless-uri-help")?.textContent || ""
);`,
),
{ description: "transport-specific unsupported message" },
);
await app.fillSelector("#proxy-vless-uri", uri);
await app.waitFor(
() =>
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.28.2";
releaseVersion = "0.29.1";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage";
hash = "sha256-+CqHiPMg4oczNiPg+MC6jvp0CUcK4kb5yeyk+QDbAWY=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_amd64.AppImage";
hash = "sha256-8EI1aUe0nuW1JsYoRD+PhIDtGbZ1SiNqKBAhFRE/K1w=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage";
hash = "sha256-HodokW2ySIpdpW7Hyqpwsm8whQ0hHldlSg11Sl1UW3k=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_aarch64.AppImage";
hash = "sha256-MLRZFU1y5dbgPhD7okCHYbC8pGrG79GT70kdD3yvqKk=";
}
else
null;
+4 -3
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.28.2",
"version": "0.29.2",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
@@ -10,8 +10,9 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:themes && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test:themes": "node --test src/lib/themes.test.mjs",
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs",
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
@@ -110,7 +111,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3"
},
"packageManager": "pnpm@11.10.0",
"packageManager": "pnpm@11.20.0",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
@@ -1,5 +1,5 @@
diff --git a/dist/commonjs/index.d.ts b/dist/commonjs/index.d.ts
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a7d061c98 100644
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..7a19917a209b84b30938957ea67d4ad60dd748c5 100644
--- a/dist/commonjs/index.d.ts
+++ b/dist/commonjs/index.d.ts
@@ -5,4 +5,5 @@ export type BraceExpansionOptions = {
@@ -8,18 +8,20 @@ index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a
export declare function expand(str: string, options?: BraceExpansionOptions): string[];
+export default expand;
//# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/dist/commonjs/index.js b/dist/commonjs/index.js
index be9df86be09c7655787a65c55ae6da01858894c3..071ad97532f30155cb56d7f2662fa99122ca628a 100644
index 869a6bee23807b9f01c18c99ab8e952b4b242f97..cd8fa65b1a1521aa0b27b3e661797763b8365138 100644
--- a/dist/commonjs/index.js
+++ b/dist/commonjs/index.js
@@ -260,4 +260,5 @@ function expand_(str, max, maxLength, isTop) {
@@ -286,4 +286,5 @@ function expand_(str, max, maxLength, isTop) {
}
return acc;
}
+module.exports = Object.assign(expand, exports);
//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a7d061c98 100644
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..7a19917a209b84b30938957ea67d4ad60dd748c5 100644
--- a/dist/esm/index.d.ts
+++ b/dist/esm/index.d.ts
@@ -5,4 +5,5 @@ export type BraceExpansionOptions = {
@@ -28,11 +30,12 @@ index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a
export declare function expand(str: string, options?: BraceExpansionOptions): string[];
+export default expand;
//# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 6dc0392fc0feedb811e63d70a30be2736af17a3a..81ea182fa5dbc3c60fa4cec4cb549fa256a903e4 100644
index fd68f57029207ac1bcafe7fb1c14ad5305b3ffa4..f3ef09ac8ad02d3fde8150e7f64f40ac874a47e3 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -256,4 +256,5 @@ function expand_(str, max, maxLength, isTop) {
@@ -282,4 +282,5 @@ function expand_(str, max, maxLength, isTop) {
}
return acc;
}
+29 -28
View File
@@ -9,21 +9,22 @@ overrides:
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.18: '>=8.5.18'
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2 <4'
fast-uri@<3.1.5: '>=3.1.5 <4'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
js-cookie@<3.0.7: '>=3.0.7'
nanoid@<3.3.17: '>=3.3.17 <4'
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
multer@>=2.0.0 <2.2.0: '>=2.2.0'
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
js-yaml@<3.15.0: '>=3.15.0 <4'
js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5'
js-yaml@<3.15.1: '>=3.15.1 <4'
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
'@babel/core@<7.29.6': '>=7.29.6 <8'
brace-expansion@<5.0.8: 5.0.8
brace-expansion@<5.0.9: 5.0.9
sharp@<0.35.0: '>=0.35.0 <0.36'
patchedDependencies:
brace-expansion@5.0.8: 6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e
brace-expansion@5.0.9: bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208
importers:
@@ -2852,8 +2853,8 @@ packages:
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
brace-expansion@5.0.8:
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
browserslist@4.28.4:
@@ -3340,8 +3341,8 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-uri@3.1.4:
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
@@ -3773,12 +3774,12 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@3.15.0:
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
js-yaml@3.15.1:
resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==}
hasBin: true
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsesc@3.1.0:
@@ -4093,8 +4094,8 @@ packages:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
nanoid@3.3.17:
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -6040,7 +6041,7 @@ snapshots:
camelcase: 5.3.1
find-up: 4.1.0
get-package-type: 0.1.0
js-yaml: 3.15.0
js-yaml: 3.15.1
resolve-from: 5.0.0
'@istanbuljs/schema@0.1.6': {}
@@ -7857,14 +7858,14 @@ snapshots:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.4
fast-uri: 3.1.5
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.4
fast-uri: 3.1.5
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -8038,7 +8039,7 @@ snapshots:
bowser@2.14.1: {}
brace-expansion@5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e):
brace-expansion@5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208):
dependencies:
balanced-match: 4.0.4
@@ -8232,7 +8233,7 @@ snapshots:
cosmiconfig@8.3.6(typescript@5.9.3):
dependencies:
import-fresh: 3.3.1
js-yaml: 4.3.0
js-yaml: 4.3.1
parse-json: 5.2.0
path-type: 4.0.0
optionalDependencies:
@@ -8480,7 +8481,7 @@ snapshots:
fast-safe-stringify@2.1.1: {}
fast-uri@3.1.4: {}
fast-uri@3.1.5: {}
fb-watchman@2.0.2:
dependencies:
@@ -9102,12 +9103,12 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@3.15.0:
js-yaml@3.15.1:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@4.3.0:
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -9332,15 +9333,15 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
minimatch@3.1.5:
dependencies:
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
minimatch@9.0.9:
dependencies:
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
minimist@1.2.8: {}
@@ -9371,7 +9372,7 @@ snapshots:
mute-stream@2.0.0: {}
nanoid@3.3.16: {}
nanoid@3.3.17: {}
napi-postinstall@0.3.4: {}
@@ -9535,7 +9536,7 @@ snapshots:
postcss@8.5.23:
dependencies:
nanoid: 3.3.16
nanoid: 3.3.17
picocolors: 1.1.1
source-map-js: 1.2.1
+6 -5
View File
@@ -24,17 +24,18 @@ overrides:
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.18: '>=8.5.18'
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2 <4'
fast-uri@<3.1.5: '>=3.1.5 <4'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
js-cookie@<3.0.7: '>=3.0.7'
nanoid@<3.3.17: '>=3.3.17 <4'
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
multer@>=2.0.0 <2.2.0: '>=2.2.0'
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
js-yaml@<3.15.0: '>=3.15.0 <4'
js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5'
js-yaml@<3.15.1: '>=3.15.1 <4'
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
'@babel/core@<7.29.6': '>=7.29.6 <8'
brace-expansion@<5.0.8: 5.0.8
brace-expansion@<5.0.9: 5.0.9
sharp@<0.35.0: '>=0.35.0 <0.36'
allowBuilds:
@@ -97,4 +98,4 @@ minimumReleaseAgeExclude:
- '@aws-sdk/token-providers@3.1081.0'
patchedDependencies:
brace-expansion@5.0.8: patches/brace-expansion@5.0.8.patch
brace-expansion@5.0.9: patches/brace-expansion@5.0.9.patch
+34
View File
@@ -0,0 +1,34 @@
# cargo-audit configuration.
#
# `cargo audit` reads Cargo.lock, which records optional dependencies even when
# no enabled feature pulls them into the build. An advisory against such a
# package fails CI while the vulnerable code is never compiled into the binary.
# Entries here are for exactly that case and must each carry the evidence.
#
# Before adding an ignore, prove the crate is genuinely not built:
# cd src-tauri
# cargo tree -i <crate> --target all # must print "nothing to print"
# cargo tree --target all | grep <crate> # must find nothing
# If either finds it, the crate IS in the build and the advisory must be fixed,
# not ignored.
[advisories]
ignore = [
# RUSTSEC-2026-0235 — rkyv: insufficient archive validation can cause
# out-of-bounds reads in archives containing Rc/Arc. Fixed in rkyv >= 0.8.17.
#
# Not reachable here. rkyv is an OPTIONAL dependency of rust_decimal, which
# arrives via tauri-plugin-log -> byte-unit -> rust_decimal. No enabled
# feature activates it, so it is present in Cargo.lock but absent from the
# build graph. Verified with the two commands above (both find nothing) and
# by there being no rkyv artifact in target/.
#
# There is no upgrade path: rust_decimal 1.42.1 is the newest release and
# still pins rkyv 0.7.x, so `cargo update` cannot reach 0.8.17.
#
# REMOVE THIS as soon as either becomes true:
# - rust_decimal ships a release depending on rkyv >= 0.8.17, or
# - `cargo tree -i rkyv --target all` starts printing a path, which would
# mean the crate is now genuinely compiled and the advisory applies.
"RUSTSEC-2026-0235",
]
+79 -3
View File
@@ -684,6 +684,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
@@ -971,6 +980,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
@@ -1797,7 +1815,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.2"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -1809,7 +1827,7 @@ dependencies = [
"blake3",
"boringtun",
"bzip2",
"cbc",
"cbc 0.2.1",
"chrono",
"chrono-tz",
"clap",
@@ -1821,6 +1839,7 @@ dependencies = [
"flate2",
"futures-util",
"globset",
"gtk",
"http-body-util",
"hyper",
"hyper-util",
@@ -1843,6 +1862,8 @@ dependencies = [
"resvg",
"ring",
"rusqlite",
"secret-service",
"security-framework",
"serde",
"serde_json",
"serde_yaml",
@@ -3322,6 +3343,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array",
]
@@ -3331,7 +3353,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding",
"block-padding 0.4.2",
"hybrid-array",
]
@@ -4010,6 +4032,20 @@ dependencies = [
"winapi",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -4020,6 +4056,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -4046,6 +4091,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -5612,6 +5667,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2 0.10.9",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -7035,6 +7109,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -8807,6 +8882,7 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
+14 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.2"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -116,8 +116,18 @@ sys-locale = "0.3"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["signal", "process"] }
# Reading the desktop's titlebar button layout for the in-app window controls.
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
# Reading the source browser's "<Brand> Safe Storage" secret during profile
# import, so its cookies and passwords can be re-encrypted for Wayfern.
secret-service = { version = "5", default-features = false, features = ["rt-tokio-crypto-rust"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
# Reading the source browser's "<Brand> Safe Storage" Keychain item during
# profile import, so its cookies and passwords can be re-encrypted for Wayfern.
security-framework = "3"
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
@@ -134,6 +144,9 @@ windows = { version = "0.62", features = [
"Win32_Storage_FileSystem",
"Win32_System_Registry",
"Win32_UI_Shell",
# CryptUnprotectData, for unwrapping the source browser's os_crypt key from
# `Local State` during profile import.
"Win32_Security_Cryptography",
] }
[dev-dependencies]
+1
View File
@@ -10,6 +10,7 @@
"core:event:allow-emit-to",
"core:event:allow-unlisten",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-minimize",
+66 -22
View File
@@ -60,8 +60,12 @@ export function requestedTarget() {
return match[1].trim();
}
function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function sha256File(path) {
return sha256(readFileSync(path));
}
export function xrayBinaryName(target) {
@@ -121,11 +125,60 @@ function extractArchive(archive, destinationDir, windowsTarget) {
};
}
/// Attempts for the archive download. A release asset fetch is a network call
/// on every CI job, and a single transport error ("fetch failed") has taken
/// whole builds down. Retrying is safe because the checksum below is verified
/// on every attempt, so a truncated or substituted archive still cannot pass.
const DOWNLOAD_ATTEMPTS = 3;
export async function downloadVerifiedArchive(url, archive, expectedSha256) {
let lastError;
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download Xray-core (${response.status} ${response.statusText})`,
);
}
// The response body is verified in memory and only then written out, so
// bytes that fail the pinned digest never reach the file system at all.
// Writing first and checking afterwards left an unverified archive on
// disk for the rest of the attempt, and any later reader of that path
// would have been trusting a plain network download.
const payload = Buffer.from(await response.arrayBuffer());
const actual = sha256(payload);
if (actual !== expectedSha256) {
throw new Error(
`Xray-core checksum mismatch: expected ${expectedSha256}, got ${actual}`,
);
}
writeFileSync(archive, payload);
return;
} catch (error) {
lastError = error;
if (attempt < DOWNLOAD_ATTEMPTS) {
console.warn(
`Xray-core download attempt ${attempt} failed (${error.message}); retrying`,
);
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
}
}
}
throw lastError;
}
export async function downloadXray(target = requestedTarget()) {
const asset = XRAY_ASSETS[target];
if (!asset) {
// `target` comes from --target/$TARGET, and it decides the file this writes
// into src-tauri/binaries. Only an own key of the pinned table is a target;
// a plain lookup also answers for inherited names like `constructor`.
if (!Object.hasOwn(XRAY_ASSETS, target)) {
throw new Error(`Xray-core is not packaged for Rust target '${target}'`);
}
const asset = XRAY_ASSETS[target];
const windowsTarget = target.includes("windows");
const destinationDir = join(MANIFEST_DIR, "binaries");
@@ -143,8 +196,8 @@ export async function downloadXray(target = requestedTarget()) {
if (
source.version === XRAY_VERSION &&
source.archiveSha256 === asset.sha256 &&
source.binarySha256 === sha256(destination) &&
source.licenseSha256 === sha256(licenseDestination)
source.binarySha256 === sha256File(destination) &&
source.licenseSha256 === sha256File(licenseDestination)
) {
return destination;
}
@@ -157,20 +210,11 @@ export async function downloadXray(target = requestedTarget()) {
const scratch = mkdtempSync(join(tmpdir(), "donut-xray-"));
try {
const archive = join(scratch, basename(asset.name));
const response = await fetch(xrayDownloadUrl(asset.name));
if (!response.ok) {
throw new Error(
`Failed to download Xray-core (${response.status} ${response.statusText})`,
);
}
writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
const actual = sha256(archive);
if (actual !== asset.sha256) {
throw new Error(
`Xray-core checksum mismatch: expected ${asset.sha256}, got ${actual}`,
);
}
await downloadVerifiedArchive(
xrayDownloadUrl(asset.name),
archive,
asset.sha256,
);
const extracted = extractArchive(archive, scratch, windowsTarget);
if (!existsSync(extracted.binary) || !existsSync(extracted.license)) {
@@ -189,8 +233,8 @@ export async function downloadXray(target = requestedTarget()) {
{
version: XRAY_VERSION,
archiveSha256: asset.sha256,
binarySha256: sha256(destination),
licenseSha256: sha256(licenseDestination),
binarySha256: sha256File(destination),
licenseSha256: sha256File(licenseDestination),
},
null,
2,
+60
View File
@@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
downloadVerifiedArchive,
downloadXray,
windowsExtractionInvocation,
XRAY_ASSETS,
@@ -122,3 +127,58 @@ test("rejects an unsupported target before downloading", async () => {
/not packaged for Rust target/,
);
});
// `constructor`, `__proto__` and friends answer a plain `XRAY_ASSETS[target]`
// lookup, and `target` picks the path this script writes into src-tauri.
test("rejects inherited object keys as targets", async () => {
for (const target of ["__proto__", "constructor", "toString"]) {
await assert.rejects(
downloadXray(target),
/not packaged for Rust target/,
target,
);
}
});
async function withStubbedFetch(body, run) {
const scratch = mkdtempSync(join(tmpdir(), "donut-xray-test-"));
const archive = join(scratch, "Xray-linux-64.zip");
const realFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(body);
try {
await run(archive);
} finally {
globalThis.fetch = realFetch;
rmSync(scratch, { recursive: true, force: true });
}
}
test("writes the archive once the pinned digest matches", async () => {
const body = Buffer.from("xray archive bytes");
const digest = createHash("sha256").update(body).digest("hex");
await withStubbedFetch(body, async (archive) => {
await downloadVerifiedArchive(
"https://example.invalid/x.zip",
archive,
digest,
);
assert.deepEqual(readFileSync(archive), body);
});
});
// The bytes are hashed in memory and only then written, so a substituted or
// truncated response never lands on disk for a later step to pick up.
test("leaves nothing on disk when the payload fails its checksum", async () => {
await withStubbedFetch(Buffer.from("tampered"), async (archive) => {
await assert.rejects(
downloadVerifiedArchive(
"https://example.invalid/x.zip",
archive,
"0".repeat(64),
),
/checksum mismatch/,
);
assert.equal(existsSync(archive), false);
});
});
+377 -45
View File
@@ -5,7 +5,10 @@ use crate::profile::manager::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
use crate::tag_manager::TAG_MANAGER;
use axum::{
extract::{Path, Query, State},
extract::{
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
Path, Query, State,
},
http::{header, HeaderMap, Method, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Json, Response},
@@ -509,6 +512,7 @@ struct ImportProxiesResponse {
run_profile,
run_profile_remote,
stop_remote_session,
remote_session_cdp,
list_remote_sessions_api,
get_remote_session_api,
get_remote_hours,
@@ -623,6 +627,7 @@ struct ImportProxiesResponse {
crate::profile_importer::DuplicateStrategy,
crate::profile_importer::ProfileImportItemResult,
crate::profile_importer::ProfileImportBatchResult,
crate::profile_import::report::ProfileImportReport,
)),
tags(
(name = "profiles", description = "Profile management endpoints"),
@@ -794,6 +799,7 @@ fn build_v1_router() -> Router<ApiServerState> {
// `/v1/remote-sessions/{id}`, and registering them separately would have
// the second overwrite the first.
.routes(routes!(get_remote_session_api, stop_remote_session))
.routes(routes!(remote_session_cdp))
.routes(routes!(list_remote_sessions_api))
.routes(routes!(get_remote_hours))
.routes(routes!(set_profile_cloud_sync))
@@ -1061,6 +1067,20 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
/// bare status code. Matching is on message content because the managers
/// return plain strings (some are the JSON `{"code": ...}` strings shared
/// with the Tauri commands).
/// Codes meaning "this profile is held by someone else right now".
///
/// Kept as one list so the REST layer, which has no other way to tell a refusal
/// apart from a validation failure, cannot drift from the guards that produce
/// them. `PROFILE_REMOTE_SYNC_PENDING` in particular is temporary by nature: the
/// pull that clears it is already running.
const LAUNCH_CONFLICT_CODES: [&str; 5] = [
"PROFILE_RUNNING",
"PROFILE_RUNNING_REMOTELY",
"PROFILE_REMOTE_SYNC_PENDING",
"PROFILE_LOCKED_BY_MEMBER",
"PROFILE_LOCKED_ELSEWHERE",
];
fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
let msg = err.to_string();
@@ -1069,8 +1089,19 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
if let Some(code) = value.get("code").and_then(|c| c.as_str()) {
let status = if code.ends_with("_NOT_FOUND") {
StatusCode::NOT_FOUND
} else if LAUNCH_CONFLICT_CODES.contains(&code) {
// Someone or something else holds this profile: another team member, a
// browser already open, or a remote session whose work has not been
// pulled back yet. All of them are "try again later", not "your request
// was malformed", and 400 would tell an automation client to give up.
StatusCode::CONFLICT
} else if code == "INTERNAL_ERROR" {
StatusCode::INTERNAL_SERVER_ERROR
} else if code == "PROFILE_LOCK_UNAVAILABLE" {
// The lock service could not be reached. The launch is refused because
// it cannot be proven safe, which is an upstream failure, not the
// caller's fault.
StatusCode::SERVICE_UNAVAILABLE
} else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") {
// Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED).
// Mapping them here lets the gate live in the shared manager instead of
@@ -2295,8 +2326,9 @@ async fn delete_extension_group_api(
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with browser automation required"),
(status = 404, description = "Profile not found"),
(status = 409, description = "Profile is locked by another team member"),
(status = 409, description = "Profile is locked by another team member, running on the remote fleet, or waiting for a finished remote session to be pulled back"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 503, description = "The profile lock service could not be reached"),
(status = 500, description = "Internal server error")
),
security(
@@ -2308,12 +2340,12 @@ async fn run_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
) -> Result<Json<RunProfileResponse>, (StatusCode, String)> {
if !crate::cloud_auth::CLOUD_AUTH
.can_use_browser_automation()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
}
let headless = request.headless.unwrap_or(false);
@@ -2322,29 +2354,34 @@ async fn run_profile(
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(manager_error_response)?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == id)
.ok_or(StatusCode::NOT_FOUND)?;
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
if profile.is_cross_os() {
return Err(StatusCode::BAD_REQUEST);
return Err((
StatusCode::BAD_REQUEST,
"cannot launch a cross-OS profile locally; use /run-remote".to_string(),
));
}
// Team lock check
// Team lock check. Routed through the shared mapper so a profile held by the
// user's OWN remote session is a 409 that says so, rather than a bare status
// with no body, which is what an automation client had to guess from.
crate::team_lock::acquire_team_lock_if_needed(profile)
.await
.map_err(|_| StatusCode::CONFLICT)?;
.map_err(manager_error_response)?;
let remote_debugging_port = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(manager_error_response)?;
let port = listener
.local_addr()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(manager_error_response)?
.port();
drop(listener);
port
@@ -2352,23 +2389,20 @@ async fn run_profile(
// Use the same launch path as the main app, but force a fresh instance with
// remote debugging enabled so the returned port is the one the browser binds.
match crate::browser_runner::launch_browser_profile_impl(
let updated_profile = crate::browser_runner::launch_browser_profile_impl(
state.app_handle.clone(),
profile.clone(),
url,
Some(remote_debugging_port),
headless,
true,
crate::browser_runner::LaunchOptions::automation(Some(remote_debugging_port), headless),
)
.await
{
Ok(updated_profile) => Ok(Json(RunProfileResponse {
profile_id: updated_profile.id.to_string(),
remote_debugging_port,
headless,
})),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
.map_err(manager_error_response)?;
Ok(Json(RunProfileResponse {
profile_id: updated_profile.id.to_string(),
remote_debugging_port,
headless,
}))
}
// API Handler - Launch this profile on a REMOTE VM of its own operating system
@@ -2734,6 +2768,157 @@ fn status_for_code(code: &str) -> StatusCode {
}
}
// API Handler - Attach a CDP client (Playwright, Puppeteer, chrome-remote-interface)
// to a remote session.
//
// This is what makes `run-remote` usable. Without it the endpoint hands back a
// session id that nothing outside this app can do anything with: the fleet's
// relay only accepts the user's Donut cloud credential, an automation client
// does not have one, and it must not be given one — an API token is scoped to
// "drive my browsers", not "act as my account".
//
// So the socket is opened here with the credential this process already holds
// and the frames are pumped verbatim in both directions. The caller presents
// the ordinary API bearer token and gets a browser-level CDP endpoint at
// `ws://127.0.0.1:<api port>/v1/remote-sessions/{id}/cdp`:
//
// const browser = await chromium.connectOverCDP({
// endpointURL: `ws://127.0.0.1:10108/v1/remote-sessions/${id}/cdp`,
// headers: { Authorization: `Bearer ${API_TOKEN}` },
// });
//
// Nothing is attached to a page first, deliberately: Playwright drives
// `Target.setAutoAttach` and builds its own session map, and a socket already
// bound to one page would hide every other target from it.
#[utoipa::path(
get,
path = "/v1/remote-sessions/{id}/cdp",
params(
("id" = String, Path, description = "Remote session ID from run-remote")
),
responses(
(status = 101, description = "Switching Protocols; a browser-level CDP WebSocket follows"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with browser automation required"),
(status = 404, description = "No such remote session, or it is not attachable yet"),
(status = 502, description = "The relay could not be reached"),
(status = 426, description = "Not a WebSocket upgrade request")
),
security(
("bearer_auth" = [])
),
tag = "remote-sessions"
)]
async fn remote_session_cdp(
Path(id): Path<String>,
upgrade: WebSocketUpgrade,
) -> Result<Response, (StatusCode, String)> {
if !crate::cloud_auth::CLOUD_AUTH
.can_use_browser_automation()
.await
{
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
}
// Dialled BEFORE the upgrade is accepted, so a session that is not attachable
// is an HTTP status the client can read. Accepting the upgrade first would
// turn every such failure into a socket that opens and immediately closes,
// which is what a CDP client reports as "browser closed unexpectedly".
let upstream = crate::cdp_target::open_relay_socket(&id)
.await
.map_err(cdp_error_response)?;
Ok(
upgrade
.max_message_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
.max_frame_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
.on_upgrade(move |client| pump_cdp(id, client, upstream)),
)
}
fn cdp_error_response(err: crate::cdp_target::CdpError) -> (StatusCode, String) {
use crate::cdp_target::CdpError;
let status = match err {
CdpError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
// "Not drivable" covers a session that is still provisioning and one that
// is not the caller's. Both are 404 to a CDP client: there is no browser at
// this address right now.
CdpError::NotDrivable(_) => StatusCode::NOT_FOUND,
CdpError::Unreachable(_) => StatusCode::BAD_GATEWAY,
CdpError::Transport(_) | CdpError::Protocol(_) => StatusCode::BAD_GATEWAY,
};
(status, err.to_string())
}
/// Copy CDP frames between the local client and the fleet relay until either
/// side hangs up.
///
/// Verbatim in both directions. This proxy deliberately understands nothing
/// about CDP: a client that speaks a newer protocol, or a target type this
/// build has never heard of, must keep working without a Donut release.
async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_target::RelaySocket) {
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as RelayMessage;
let (mut client_tx, mut client_rx) = client.split();
let (mut relay_tx, mut relay_rx) = upstream.split();
let to_relay = async {
while let Some(Ok(message)) = client_rx.next().await {
let forwarded = match message {
WsMessage::Text(text) => RelayMessage::Text(text.as_str().into()),
WsMessage::Binary(bytes) => RelayMessage::Binary(bytes),
WsMessage::Ping(bytes) => RelayMessage::Ping(bytes),
WsMessage::Pong(bytes) => RelayMessage::Pong(bytes),
WsMessage::Close(_) => break,
};
if relay_tx.send(forwarded).await.is_err() {
break;
}
}
let _ = relay_tx.close().await;
};
let to_client = async {
while let Some(Ok(message)) = relay_rx.next().await {
let forwarded = match message {
RelayMessage::Text(text) => WsMessage::Text(text.as_str().into()),
RelayMessage::Binary(bytes) => WsMessage::Binary(bytes),
RelayMessage::Ping(bytes) => WsMessage::Ping(bytes),
RelayMessage::Pong(bytes) => WsMessage::Pong(bytes),
// A relay close carries the only diagnosis the server gives (1008 is a
// rejected credential, 1013 is "not up yet"), so it is passed through
// rather than swallowed into a bare disconnect.
RelayMessage::Close(frame) => {
let _ = client_tx
.send(WsMessage::Close(frame.map(|f| {
axum::extract::ws::CloseFrame {
code: u16::from(f.code),
reason: f.reason.as_str().into(),
}
})))
.await;
return;
}
RelayMessage::Frame(_) => continue,
};
if client_tx.send(forwarded).await.is_err() {
break;
}
}
let _ = client_tx.close().await;
};
// Either direction ending means the conversation is over. Waiting for both
// would hold a relay socket open — and one of the session's four allowed
// attachments with it — after the client had gone.
tokio::select! {
() = to_relay => {}
() = to_client => {}
}
log::info!("CDP proxy for remote session {session_id} closed");
}
// API Handler - Every remote session this account currently owns
#[utoipa::path(
get,
@@ -2849,7 +3034,7 @@ fn cookie_bot_eligible_profile(
.find(|p| p.id.to_string() == profile_id)
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
crate::cookie_bot::bot_precondition(&profile)
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
.map_err(|reason| (StatusCode::BAD_REQUEST, reason))?;
Ok(profile)
}
@@ -3131,10 +3316,7 @@ async fn list_cookie_bot_runs(
async fn start_cookie_bot_run(
Json(request): Json<StartCookieBotRunRequest>,
) -> Result<(StatusCode, Json<crate::cookie_bot::CookieBotRunStarted>), (StatusCode, String)> {
if !crate::cloud_auth::CLOUD_AUTH
.can_use_browser_automation()
.await
{
if !crate::cloud_auth::CLOUD_AUTH.can_use_cookie_bot().await {
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
}
@@ -3240,6 +3422,11 @@ async fn get_cookie_bot_usage(
}
// API Handler - Open URL in existing browser
//
// Works against a profile running here OR one running on the leased fleet: a
// remote session is navigated over the same CDP path the automation tools use,
// so a caller does not have to know where the browser is. The cross-OS refusal
// therefore only applies to a profile that would have to be launched locally.
#[utoipa::path(
post,
path = "/v1/profiles/{id}/open-url",
@@ -3248,12 +3435,14 @@ async fn get_cookie_bot_usage(
),
request_body = OpenUrlRequest,
responses(
(status = 200, description = "URL opened successfully"),
(status = 400, description = "Cannot open URL with a cross-OS profile"),
(status = 200, description = "URL opened successfully, locally or on the profile's remote session"),
(status = 400, description = "Cannot open URL with a cross-OS profile that is not running remotely"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with browser automation required"),
(status = 404, description = "Profile not found"),
(status = 409, description = "Profile is locked by another team member, or waiting for a finished remote session to be pulled back"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 503, description = "The profile lock service could not be reached"),
(status = 500, description = "Internal server error")
),
security(
@@ -3276,7 +3465,12 @@ async fn open_url_in_profile(
let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner
.open_url_with_profile(state.app_handle.clone(), id, request.url)
.open_url_with_profile(
state.app_handle.clone(),
id,
request.url,
crate::launch_gate::FingerprintGate::Advisory,
)
.await
.map_err(manager_error_response)?;
@@ -3284,6 +3478,12 @@ async fn open_url_in_profile(
}
// API Handler - Kill browser process
//
// Stops the browser wherever it is. A profile open on the leased fleet is ended
// through the backend, which is what makes this endpoint mean "stop this
// profile" rather than "stop this profile if it happens to be on this machine" —
// the latter reported success, killed nothing, and left the session billing to
// its two-hour cap.
#[utoipa::path(
post,
path = "/v1/profiles/{id}/kill",
@@ -3291,11 +3491,12 @@ async fn open_url_in_profile(
("id" = String, Path, description = "Profile ID")
),
responses(
(status = 204, description = "Browser process killed successfully"),
(status = 204, description = "Browser stopped, locally or on the profile's remote session"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan required"),
(status = 404, description = "Profile not found"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 503, description = "The fleet could not be reached; the remote browser is still running"),
(status = 500, description = "Internal server error")
),
security(
@@ -3306,31 +3507,41 @@ async fn open_url_in_profile(
async fn kill_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
) -> Result<StatusCode, (StatusCode, String)> {
// Programmatically launching and stopping profiles is a paid feature; the
// run/open-url handlers gate the same way.
if !crate::cloud_auth::CLOUD_AUTH
.can_use_browser_automation()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
}
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(manager_error_response)?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == id)
.ok_or(StatusCode::NOT_FOUND)?;
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner
.kill_browser_process(state.app_handle.clone(), profile)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|e| {
let message = e.to_string();
// The backend refuses to retire a session it could not stop on the fleet.
// Reporting that as a 500 invites a retry loop against a browser that is
// still running; 503 says "it is still up, try again".
if message.contains("REMOTE_") {
(StatusCode::SERVICE_UNAVAILABLE, message)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, message)
}
})?;
crate::team_lock::release_team_lock_if_needed(profile).await;
@@ -3416,9 +3627,7 @@ async fn batch_run_profiles(
state.app_handle.clone(),
profile.clone(),
request.url.clone(),
Some(port),
headless,
true,
crate::browser_runner::LaunchOptions::automation(Some(port), headless),
)
.await
{
@@ -4023,14 +4232,22 @@ mod tests {
let mut local_only = profile_with(SyncMode::Disabled, Some("macos"));
local_only.proxy_id = Some("proxy-1".to_string());
assert!(
crate::cookie_bot::bot_precondition(&local_only).is_err(),
crate::cookie_bot::bot_precondition(
&local_only,
&crate::remote_exit::ExitReachability::Remote
)
.is_err(),
"a profile with no cloud copy has nothing for a host to open"
);
let mut encrypted = profile_with(SyncMode::Encrypted, Some("macos"));
encrypted.proxy_id = Some("proxy-1".to_string());
assert!(
crate::cookie_bot::bot_precondition(&encrypted).is_err(),
crate::cookie_bot::bot_precondition(
&encrypted,
&crate::remote_exit::ExitReachability::Remote
)
.is_err(),
"a host cannot decrypt a profile whose key never leaves this machine"
);
@@ -4038,13 +4255,21 @@ mod tests {
datacenter_egress.proxy_id = None;
datacenter_egress.vpn_id = None;
assert!(
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
crate::cookie_bot::bot_precondition(
&datacenter_egress,
&crate::remote_exit::ExitReachability::None
)
.is_err(),
"hours of traffic from a hosting ASN damages the identity being warmed"
);
let mut eligible = profile_with(SyncMode::Regular, Some("macos"));
eligible.proxy_id = Some("proxy-1".to_string());
assert!(crate::cookie_bot::bot_precondition(&eligible).is_ok());
assert!(crate::cookie_bot::bot_precondition(
&eligible,
&crate::remote_exit::ExitReachability::Remote
)
.is_ok());
}
#[test]
@@ -4221,13 +4446,29 @@ mod tests {
}
let import_item = schema_required(&spec, "ImportProfileItem");
for field in ["proxy_id", "vpn_id", "browser_type"] {
for field in ["proxy_id", "vpn_id", "browser_type", "allow_running"] {
assert!(
!import_item.iter().any(|f| f == field),
"{field} must be optional on import items, required list: {import_item:?}"
);
}
// The per-item report only exists for items that actually imported.
let import_result = schema_required(&spec, "ProfileImportItemResult");
assert!(
!import_result.iter().any(|f| f == "report"),
"report must be optional on import results, required list: {import_result:?}"
);
// `ProfileImportItemResult` references it, so a missing registration would
// leave a dangling $ref in the served spec.
assert!(
spec
.pointer("/components/schemas/ProfileImportReport")
.is_some(),
"ProfileImportReport must be registered in ApiDoc components"
);
// A remote launch with no URL just opens the browser; forcing generated
// clients to send one would make the common case the awkward one.
let run_remote = schema_required(&spec, "RunRemoteRequest");
@@ -4325,12 +4566,103 @@ mod tests {
assert!(parsed.group_id.is_none());
assert!(parsed.duplicate_strategy.is_none());
assert_eq!(parsed.items[0].browser_type, "chromium");
assert_eq!(parsed.items[0].allow_running, None);
}
// The served /openapi.json comes from the hand-maintained ApiDoc `paths(...)`
// list, not from the router — endpoints registered on the router but missing
// from ApiDoc silently disappear from the spec. Lock in the ones that were
// once dropped, and that removed endpoints stay gone.
#[test]
fn a_profile_held_elsewhere_is_a_conflict_not_a_bad_request() {
// These four refusals all mean "come back in a moment". Answering 400 tells
// an automation client its request was malformed and to stop retrying, and
// that is what every one of them did before they had codes at all.
for code in [
"PROFILE_RUNNING_REMOTELY",
"PROFILE_REMOTE_SYNC_PENDING",
"PROFILE_LOCKED_BY_MEMBER",
"PROFILE_LOCKED_ELSEWHERE",
] {
let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
assert_eq!(status, StatusCode::CONFLICT, "{code} must be a 409");
assert!(body.contains(code), "{code} must reach the caller");
}
}
#[test]
fn an_unreachable_lock_service_is_not_the_callers_fault() {
let (status, _) =
manager_error_response(serde_json::json!({ "code": "PROFILE_LOCK_UNAVAILABLE" }).to_string());
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() {
// Without this route `run-remote` hands back a session id that nothing
// outside the app can use: the fleet relay accepts only the user's cloud
// credential, which an API consumer does not have and must not be given.
// A Playwright user reads the spec to find this, so it has to be in it.
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
let operation = &spec["paths"]["/v1/remote-sessions/{id}/cdp"]["get"];
assert!(
operation.is_object(),
"the CDP attach endpoint must be in the served spec"
);
assert!(
operation["responses"].get("101").is_some(),
"a WebSocket endpoint must document its upgrade"
);
assert_eq!(operation["tags"][0], "remote-sessions");
}
#[test]
fn a_cdp_attach_failure_is_not_reported_as_a_broken_relay() {
// A CDP client retries a 502 and gives up on a 404. Reporting "this session
// is not up yet" as a gateway failure sends it into a loop against a
// session that is doing exactly what it should.
use crate::cdp_target::CdpError;
assert_eq!(
cdp_error_response(CdpError::NotDrivable("provisioning".into())).0,
StatusCode::NOT_FOUND
);
assert_eq!(
cdp_error_response(CdpError::Unauthorized("no token".into())).0,
StatusCode::UNAUTHORIZED
);
assert_eq!(
cdp_error_response(CdpError::Unreachable("dns".into())).0,
StatusCode::BAD_GATEWAY
);
}
#[test]
fn the_kill_route_documents_that_it_can_fail_to_stop_a_remote_browser() {
// The backend refuses to retire a session it could not stop on the fleet, so
// stopping can genuinely fail with the browser still running. A spec that
// only lists 204 tells a client that never happens.
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
let responses = &spec["paths"]["/v1/profiles/{id}/kill"]["post"]["responses"];
assert!(
responses.get("503").is_some(),
"kill must document that the fleet may be unreachable"
);
}
#[test]
fn the_local_launch_routes_document_their_conflict() {
// A profile waiting on a finished remote session refuses a local launch.
// Undocumented, that reaches an integrator as an unexplained 409.
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
for path in ["/v1/profiles/{id}/run", "/v1/profiles/{id}/open-url"] {
let responses = &spec["paths"][path]["post"]["responses"];
assert!(
responses.get("409").is_some(),
"{path} must document its conflict"
);
}
}
#[test]
fn openapi_spec_covers_registered_routes() {
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
+82 -2
View File
@@ -231,7 +231,7 @@ mod windows {
pub fn is_wayfern_version_downloaded(install_dir: &Path) -> bool {
if wayfern_executable_candidates(install_dir)
.iter()
.any(|exe_path| exe_path.exists() && exe_path.is_file())
.any(|exe_path| exe_path.exists() && exe_path.is_file() && has_sibling_dll(exe_path))
{
return true;
}
@@ -239,7 +239,8 @@ mod windows {
// Check for any .exe file that looks like the browser
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
if is_wayfern_exe(&entry.path()) {
let path = entry.path();
if is_wayfern_exe(&path) && has_sibling_dll(&path) {
return true;
}
}
@@ -380,6 +381,33 @@ impl BrowserFactory {
}
}
/// Whether the directory holding `exe_path` also contains at least one `.dll`.
///
/// A Chromium build on Windows cannot start without its sibling libraries
/// (`chrome.dll` and friends) and its `.manifest`; a lone `.exe` is a gutted
/// install, and launching it fails inside the Windows loader with os error
/// 14001 (`ERROR_SXS_CANT_GEN_ACTCTX`, "side-by-side configuration is
/// incorrect"). Treating such a directory as downloaded is what made that state
/// permanent: the registry rescan re-added it as a healthy install, so no
/// re-download was ever offered. The check is scoped to the executable's own
/// directory because the payload may sit at the version root or in a `bin/`,
/// `wayfern/`, `wayfern-win/` or `chrome-win/` subdirectory.
#[cfg(any(target_os = "windows", test))]
fn has_sibling_dll(exe_path: &Path) -> bool {
let Some(dir) = exe_path.parent() else {
return false;
};
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|entry| {
entry
.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("dll"))
})
}
/// Check if a file is a valid PE executable by reading its magic bytes (MZ).
/// Returns false for archive files (.zip starts with PK, etc.) that were
/// incorrectly named with a .exe extension.
@@ -575,6 +603,58 @@ mod tests {
assert!(exe.ends_with(std::path::Path::new("wayfern-win").join("wayfern.exe")));
}
/// A gutted Windows install (the `.exe` survived a cleanup pass that deleted
/// every `.dll` and the `.manifest`) must not read as downloaded, otherwise it
/// is re-registered as healthy and launching it fails with os error 14001.
/// Runs on every platform because the predicate is platform-independent.
#[test]
fn test_lone_exe_is_not_a_valid_windows_install() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let install_dir = temp.path();
let exe = install_dir.join("chrome.exe");
std::fs::File::create(&exe).unwrap();
assert!(
!has_sibling_dll(&exe),
"an .exe with no sibling .dll is a gutted install"
);
std::fs::File::create(install_dir.join("chrome.dll")).unwrap();
assert!(
has_sibling_dll(&exe),
"an .exe next to its libraries is a complete install"
);
}
/// The DLL check is scoped to the executable's own directory, so the nested
/// `chrome-win/` and `wayfern-win/` layouts are not falsely rejected because
/// the version root happens to hold no libraries.
#[test]
fn test_sibling_dll_check_is_scoped_to_the_executable_directory() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let install_dir = temp.path();
let subdir = install_dir.join("chrome-win");
std::fs::create_dir_all(&subdir).unwrap();
let exe = subdir.join("chrome.exe");
std::fs::File::create(&exe).unwrap();
std::fs::File::create(subdir.join("CHROME.DLL")).unwrap();
assert!(
has_sibling_dll(&exe),
"libraries beside the executable count regardless of case or nesting"
);
let root_exe = install_dir.join("chrome.exe");
std::fs::File::create(&root_exe).unwrap();
assert!(
!has_sibling_dll(&root_exe),
"libraries in a sibling subdirectory must not validate a bare root .exe"
);
}
#[test]
fn test_proxy_settings_serialization() {
let proxy = ProxySettings {
+387 -64
View File
@@ -15,6 +15,13 @@ static PROFILE_LAUNCH_LOCKS: LazyLock<
tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
> = LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new()));
/// How long a remote navigation waits for the page to settle.
///
/// A relayed round trip crosses two networks and the page load itself happens
/// on hardware in another country, so this is deliberately the same budget the
/// automation tools give a navigation rather than a loopback-sized one.
const REMOTE_NAVIGATE_TIMEOUT_SECS: u64 = 30;
async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<()> {
let lock = {
let mut locks = PROFILE_LAUNCH_LOCKS.lock().await;
@@ -165,12 +172,15 @@ impl BrowserRunner {
});
}
/// Resolve the upstream a launch will use.
///
/// Deliberately does NOT fire the launch hook: that moved below the gate, so
/// a launch the user blocks and then retries calls the user's webhook once
/// rather than once per attempt.
async fn resolve_launch_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
Self::fire_launch_hook(profile);
self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await
@@ -203,9 +213,9 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
_local_proxy_settings: Option<&ProxySettings>,
remote_debugging_port: Option<u16>,
headless: bool,
gate: &crate::launch_gate::FingerprintGate,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Handle Wayfern profiles using WayfernManager
if profile.browser == "wayfern" {
@@ -270,12 +280,61 @@ impl BrowserRunner {
upstream_proxy = Some(worker.local_proxy_settings());
}
/// Stops a VPN worker this launch started, if the launch then fails.
///
/// `created` is the whole point: `start_vpn_worker` reuses a live worker
/// for the same VPN, so an unconditional stop would sever the tunnel a
/// *different* profile is browsing through the moment this one is
/// cancelled. The in-use check is a second belt for a worker adopted by a
/// browser that started between the two points.
struct VpnLaunchGuard {
worker_id: Option<String>,
vpn_id: String,
created: bool,
profile_name: String,
}
impl Drop for VpnLaunchGuard {
fn drop(&mut self) {
let Some(worker_id) = self.worker_id.take() else {
return;
};
if !self.created {
return;
}
log::warn!(
"Launch failed after VPN worker start for profile {}; stopping worker",
self.profile_name
);
let vpn_id = self.vpn_id.clone();
tauri::async_runtime::spawn(async move {
// Serialize against worker startup for the whole check-then-stop.
// Without it another launch can adopt this worker between the
// in-use check and the kill, and lose its tunnel a moment later.
let _adopt_guard = crate::vpn_worker_runner::lock_vpn_starts().await;
if crate::vpn_worker_runner::vpn_id_in_use_by_running_browser(&vpn_id) {
log::info!("VPN {vpn_id} is still in use by a running browser; leaving it up");
return;
}
if let Err(error) = crate::vpn_worker_runner::stop_vpn_worker(&worker_id).await {
log::warn!("Failed to stop VPN worker after failed launch: {error}");
}
});
}
}
let mut vpn_launch_guard: Option<VpnLaunchGuard> = None;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
if let Some(ref vpn_id) = profile.vpn_id {
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
Ok(vpn_worker) => {
if let Some(port) = vpn_worker.local_port {
match crate::vpn_worker_runner::start_vpn_worker_tracked(vpn_id).await {
Ok(started) => {
vpn_launch_guard = Some(VpnLaunchGuard {
worker_id: Some(started.config.id.clone()),
vpn_id: vpn_id.clone(),
created: started.created,
profile_name: profile.name.clone(),
});
if let Some(port) = started.config.local_port {
upstream_proxy = Some(ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
@@ -288,12 +347,33 @@ impl BrowserRunner {
}
}
Err(e) => {
return Err(format!("Failed to start VPN worker: {e}").into());
return Err(crate::backend_error_with_detail("VPN_WORKER_START_FAILED", e).into());
}
}
}
}
// The gate sits exactly here on purpose. By this line the upstream is
// fully normalized across all three transports — VLESS and VPN are
// authenticated loopback workers, a stored proxy is its resolved
// settings — so one probe covers every profile. And it is still ahead of
// the local proxy worker, the decrypted profile copy, the extension
// unpack, and the browser process, so a blocked launch has nothing to
// undo beyond the two workers whose guards are already armed above.
//
// Run concurrently with the blocklist compile so the added wall clock is
// max(), not sum().
let (blocklist, gate_result) = tokio::join!(
Self::resolve_blocklist_file(profile),
crate::launch_gate::enforce_fingerprint_gate(profile, upstream_proxy.as_ref(), gate),
);
gate_result.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let (blocklist_file, dns_allowlist_mode) = blocklist?;
// Past the gate: this launch is really happening, so tell the user's
// webhook exactly once.
Self::fire_launch_hook(profile);
log::info!(
"Starting local proxy for Wayfern profile: {} (upstream: {})",
profile.name,
@@ -306,7 +386,6 @@ impl BrowserRunner {
// Start the proxy and get local proxy settings
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
let profile_id_str = profile.id.to_string();
let (blocklist_file, dns_allowlist_mode) = Self::resolve_blocklist_file(profile).await?;
// Unique per-launch key: a shared constant here would let concurrent
// launches overwrite each other's active_proxies entry, ending with one
// browser's worker tracked under another browser's PID.
@@ -461,6 +540,25 @@ impl BrowserRunner {
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path =
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir);
// Profiles imported by builds before the layout fix have their content at
// the user-data-dir root instead of under `Default/`, so the browser has
// never seen a byte of it. Move it into place now, while the profile is
// provably not running. Secrets stay unreadable — the source key was
// never captured and cannot be recovered after the fact — but history,
// bookmarks, extensions and site data come back.
match crate::profile_import::repair_legacy_layout(&profile_data_path) {
Ok(true) => log::info!(
"Repaired legacy import layout for profile: {}",
updated_profile.name
),
Ok(false) => {}
Err(e) => log::warn!(
"Could not repair legacy import layout for {}: {e}",
updated_profile.name
),
}
let profile_path_str = profile_data_path.to_string_lossy().to_string();
// Install extensions if an extension group is assigned
@@ -546,11 +644,14 @@ impl BrowserRunner {
}
}
// The browser and both detached routing workers now share one verified
// The browser and every detached routing worker now share one verified
// process identity, so later profile-persistence failures must not tear
// down a live route.
proxy_launch_guard.armed = false;
xray_launch_guard.worker_id = None;
if let Some(guard) = vpn_launch_guard.as_mut() {
guard.worker_id = None;
}
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
// applied, which may be UPGRADED from the stored one (e.g. when the
@@ -687,6 +788,7 @@ impl BrowserRunner {
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
gate: &crate::launch_gate::FingerprintGate,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Wayfern starts (and PID-reconciles) its own local proxy
// inside `launch_browser_internal`, so we hand it None here rather than
@@ -696,9 +798,9 @@ impl BrowserRunner {
app_handle,
profile,
url,
None,
remote_debugging_port,
headless,
gate,
)
.await
}
@@ -709,6 +811,7 @@ impl BrowserRunner {
profile: &BrowserProfile,
url: Option<String>,
internal_proxy_settings: Option<&ProxySettings>,
gate: &crate::launch_gate::FingerprintGate,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
log::info!(
"launch_or_open_url called for profile: {} (ID: {})",
@@ -789,14 +892,7 @@ impl BrowserRunner {
} else {
log::info!("Launching new browser instance - browser not running");
self
.launch_browser_internal(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
None,
false,
)
.launch_browser_internal(app_handle.clone(), &final_profile, url, None, false, gate)
.await
}
}
@@ -829,11 +925,64 @@ impl BrowserRunner {
profile: &BrowserProfile,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
// "Stop this profile" has to mean the browser that is actually running, and
// for a profile on the leased fleet that browser is not on this machine.
// Without this, stopping reported success, killed nothing, and left the
// session running to its two-hour cap — billing the user for every minute
// and holding their profile lock the whole time.
if self.stop_remote_session_for(&app_handle, profile).await? {
return Ok(());
}
self
.kill_browser_process_unlocked(app_handle, profile)
.await
}
/// Stop this profile's fleet session, if it has one. Returns whether it did.
///
/// Guarded on there being no local process so a locally running profile never
/// pays for the lookup, exactly as the open-URL path is: the profile lock
/// makes a local and a remote browser mutually exclusive.
async fn stop_remote_session_for(
&self,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
if profile.process_id.is_some() {
return Ok(false);
}
let profile_id = profile.id.to_string();
let Some(session_id) = crate::remote_handoff::running_session_for_profile(&profile_id) else {
return Ok(false);
};
log::info!(
"Stopping remote session {session_id} for profile {} ({profile_id})",
profile.name
);
crate::remote_session::end_remote_session(&session_id)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
// Surfaced rather than swallowed. The backend refuses to retire a
// session it could not stop on the fleet, so a failure here means the
// browser is STILL RUNNING; reporting success would tell the user their
// profile is free when a host is still writing to it.
log::warn!("Failed to stop remote session {session_id}: {e}");
e.to_error_json().into()
})?;
// The session is down and its work is in cloud storage. This is what puts
// the profile into "pending sync" and starts the pull, so the user is not
// handed back a profile directory that predates the session they just ran.
//
// The session's own profile lock is released by the backend when it retires
// the row; nothing is released from here, because this client never held it.
crate::remote_session::note_session_stopped(app_handle, &session_id);
Ok(true)
}
async fn kill_browser_process_unlocked(
&self,
app_handle: tauri::AppHandle,
@@ -1210,6 +1359,7 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile_id: String,
url: String,
gate: crate::launch_gate::FingerprintGate,
) -> Result<(), String> {
// Get the profile by name
let profiles = self
@@ -1222,6 +1372,29 @@ impl BrowserRunner {
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
// A profile already open on the leased fleet is driven, not launched. This
// sits above the cross-OS guard on purpose: a Windows profile cannot run on
// this Mac, which is the whole reason it is running remotely, and refusing
// to point it at a URL for that reason would make the remote session
// unusable from the one endpoint that exists to use it.
//
// Guarded on there being no local process, so a profile running here never
// pays for the lookup: a local launch records a pid, and the profile lock
// keeps a local and a remote session mutually exclusive.
if profile.process_id.is_none() {
if let Ok(target) = crate::cdp_target::resolve(&profile).await {
if target.is_remote() {
log::info!("Opening URL through {}", target.describe());
return crate::cdp_target::navigate(&target, &url, REMOTE_NAVIGATE_TIMEOUT_SECS)
.await
.map_err(|e| {
log::warn!("Failed to open a URL on the remote browser: {e}");
format!("Failed to open URL with profile: {e}")
});
}
}
}
if profile.is_cross_os() {
return Err(format!(
"Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system",
@@ -1230,19 +1403,36 @@ impl BrowserRunner {
));
}
// Past this point a local browser is about to be launched, and until now
// this was the ONE launch path that took neither the profile lock nor any
// notice of the fleet. A remote session whose state could not be read (a
// dropped event stream plus an unreachable backend) fell straight through
// to a local launch on a profile a host was writing to.
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
log::info!("Opening URL with selected profile");
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
self
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
if let Err(e) = self
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None, &gate)
.await
.map_err(|e| {
log::info!(
"Failed to open URL with selected profile: {}",
crate::log_redaction::text(&e.to_string())
);
format!("Failed to open URL with profile: {e}")
})?;
{
log::info!(
"Failed to open URL with selected profile: {}",
crate::log_redaction::text(&e.to_string())
);
// This path takes the team lock too, and a blocked launch never records a
// process_id for the status sweep to release it from.
unwind_launch(&profile, acquired_team_lock).await;
// Pass structured errors through untouched: the gate's block carries the
// mismatch detail the dialog renders, and wrapping it in English would
// reach the user as raw JSON.
return Err(crate::wrap_backend_error(
e,
"Failed to open URL with profile",
));
}
log::info!("Successfully opened URL with selected profile");
Ok(())
@@ -1254,18 +1444,115 @@ pub async fn launch_browser_profile(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
consent_token: Option<String>,
) -> Result<BrowserProfile, String> {
launch_browser_profile_impl(app_handle, profile, url, None, false, false).await
let options = LaunchOptions {
gate: match consent_token {
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
None => crate::launch_gate::FingerprintGate::Enforce,
},
..Default::default()
};
launch_browser_profile_impl(app_handle, profile, url, options).await
}
/// How one launch should behave.
///
/// A struct rather than four trailing positional arguments: `headless` and
/// `force_new` are already passed adjacently as bare booleans, so a fifth would
/// compile everywhere while silently inverting behavior wherever the order was
/// got wrong.
#[derive(Debug, Clone, Default)]
pub struct LaunchOptions {
pub remote_debugging_port: Option<u16>,
pub headless: bool,
pub force_new: bool,
pub gate: crate::launch_gate::FingerprintGate,
}
impl LaunchOptions {
/// Automation defaults: report, never block, never probe. A headless client
/// has no dialog to answer and cannot regenerate its fingerprint mid-run, so
/// a hard failure would turn a warning into an outage for a whole fleet.
pub fn automation(remote_debugging_port: Option<u16>, headless: bool) -> Self {
Self {
remote_debugging_port,
headless,
force_new: true,
gate: crate::launch_gate::FingerprintGate::Advisory,
}
}
}
/// Release the team lock a launch attempt took before it failed.
///
/// Until the gate existed, failing here was rare enough that leaking was merely
/// untidy. Cancelling a blocked launch is now an ordinary outcome, and the lock
/// renews itself on a 30s heartbeat while only ever being released via a stored
/// `process_id` — which a launch that never spawned does not have. So a leak
/// leaves the profile reading as locked to the whole team until the app quits.
///
/// `acquired` is threaded from `acquire_team_lock_if_needed` so this releases
/// only what this call took, never a lock a REST handler up the stack owns.
///
/// Several of these error paths are reachable while a browser for the profile
/// is genuinely still running — `PROFILE_RUNNING`, or a failure to open a URL
/// in an existing window. That browser owns the lock and the running mark, so
/// releasing either would strand it: the team would see the profile as free
/// while someone is typing in it, and `mark_profile_stopped` would queue a sync
/// of a profile directory being written to. Hence the liveness check.
async fn unwind_launch(profile: &BrowserProfile, acquired_team_lock: bool) {
if browser_is_running_for(&profile.id.to_string()) {
log::debug!(
"Not unwinding launch state for {}: a browser is still running for it",
profile.name
);
return;
}
if acquired_team_lock {
crate::team_lock::release_team_lock_if_needed(profile).await;
}
// Otherwise this mark sticks for the rest of the session and silently defers
// every sync of the profile. Safe here precisely because nothing is running.
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler
.mark_profile_stopped(&profile.id.to_string())
.await;
}
}
/// Whether a live browser process is recorded for this profile right now.
/// Re-read from disk: the caller's copy predates the launch attempt.
fn browser_is_running_for(profile_id: &str) -> bool {
BrowserRunner::instance()
.profile_manager
.list_profiles()
.ok()
.and_then(|profiles| {
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.map(|p| {
p.process_id
.is_some_and(crate::proxy_storage::is_process_running)
})
})
.unwrap_or(false)
}
pub async fn launch_browser_profile_impl(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
force_new: bool,
options: LaunchOptions,
) -> Result<BrowserProfile, String> {
let LaunchOptions {
remote_debugging_port,
headless,
force_new,
gate,
} = options;
log::info!(
"Launch request received for profile: {} (ID: {})",
profile.name,
@@ -1281,8 +1568,14 @@ pub async fn launch_browser_profile_impl(
));
}
// Refuse a launch that would run over work a remote session has not handed
// back yet. Checked before the profile lock because it answers without a
// round trip and because it stays true after the session's lock is released:
// the lock protects the browser, this protects the bytes it wrote.
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
// Notify sync scheduler that profile is now running and queue sync for when it stops
if let Some(scheduler) = crate::sync::get_global_scheduler() {
@@ -1306,6 +1599,7 @@ pub async fn launch_browser_profile_impl(
.find(|p| p.id == profile.id)
.unwrap_or_else(|| profile.clone()),
Err(e) => {
unwind_launch(&profile, acquired_team_lock).await;
return Err(e);
}
};
@@ -1322,15 +1616,24 @@ pub async fn launch_browser_profile_impl(
profile_for_launch.id
);
if force_new
&& browser_runner
if force_new {
let already_running = match browser_runner
.check_browser_status(app_handle.clone(), &profile_for_launch)
.await
.map_err(|error| {
crate::wrap_backend_error(error, "Failed to check browser status before launch")
})?
{
return Err(crate::backend_error("PROFILE_RUNNING"));
{
Ok(running) => running,
Err(error) => {
unwind_launch(&profile, acquired_team_lock).await;
return Err(crate::wrap_backend_error(
error,
"Failed to check browser status before launch",
));
}
};
if already_running {
unwind_launch(&profile, acquired_team_lock).await;
return Err(crate::backend_error("PROFILE_RUNNING"));
}
}
// Launch browser or open URL in existing instance. Wayfern starts its
@@ -1348,39 +1651,54 @@ pub async fn launch_browser_profile_impl(
url,
remote_debugging_port,
headless,
&gate,
)
.await
} else {
browser_runner
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None)
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None, &gate)
.await
};
let updated_profile = launch_result.map_err(|e| {
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
let updated_profile = match launch_result {
Ok(updated) => updated,
Err(e) => {
log::info!(
"Browser launch failed for profile: {}, error: {}",
profile_for_launch.name,
e
);
// Emit a failure event to clear loading states in the frontend
#[derive(serde::Serialize)]
struct RunningChangedPayload {
id: String,
is_running: bool,
}
let payload = RunningChangedPayload {
id: profile_for_launch.id.to_string(),
is_running: false,
};
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
}
// Check if this is an architecture compatibility issue
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
if io_error.kind() == std::io::ErrorKind::Other && io_error.to_string().contains("Exec format error") {
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
// Emit a failure event to clear loading states in the frontend
#[derive(serde::Serialize)]
struct RunningChangedPayload {
id: String,
is_running: bool,
}
let payload = RunningChangedPayload {
id: profile_for_launch.id.to_string(),
is_running: false,
};
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
}
unwind_launch(&profile, acquired_team_lock).await;
// Check if this is an architecture compatibility issue
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
if io_error.kind() == std::io::ErrorKind::Other
&& io_error.to_string().contains("Exec format error")
{
return Err(format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH));
}
}
return Err(crate::wrap_backend_error(
e,
"Failed to launch browser or open URL",
));
}
crate::wrap_backend_error(e, "Failed to launch browser or open URL")
})?;
};
log::info!(
"Browser launch completed for profile: {} (ID: {})",
@@ -1513,10 +1831,15 @@ pub async fn open_url_with_profile(
app_handle: tauri::AppHandle,
profile_id: String,
url: String,
consent_token: Option<String>,
) -> Result<(), String> {
let browser_runner = BrowserRunner::instance();
let gate = match consent_token {
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
None => crate::launch_gate::FingerprintGate::Enforce,
};
browser_runner
.open_url_with_profile(app_handle, profile_id, url)
.open_url_with_profile(app_handle, profile_id, url, gate)
.await
}
File diff suppressed because it is too large Load Diff
+51 -11
View File
@@ -28,9 +28,9 @@ const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
/// Capability + limit set the account is entitled to, derived from its plan.
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
/// flags instead of a single "is paid?" boolean, so a plan like the future
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
/// data here.
/// flags instead of a single "is paid?" boolean, so a plan like "solo" (cloud
/// backup + nightly cookie bot, no automation, no fingerprint editing, no
/// hands-on remote session) is just data here.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entitlements {
#[serde(default)]
@@ -49,6 +49,12 @@ pub struct Entitlements {
/// together.
#[serde(rename = "cookieBot", default)]
pub cookie_bot: bool,
/// Whether the plan may open a HANDS-ON remote session. Distinct from
/// `cookie_bot`: solo funds a nightly bot out of its remote hours but may not
/// drive a remote browser itself, so anything that offers interactive remote
/// control must read THIS rather than `remote_browser_hours > 0`.
#[serde(rename = "remoteInteractive", default)]
pub remote_interactive: bool,
#[serde(rename = "profileLimit", default)]
pub profile_limit: i64,
#[serde(rename = "requestsPerHour", default)]
@@ -77,16 +83,29 @@ fn derive_entitlements(
cloud_backup: false,
team_collaboration: false,
cookie_bot: false,
remote_interactive: false,
profile_limit: 0,
requests_per_hour: 0,
remote_browser_hours: 0,
};
}
// pro and any unrecognized paid plan -> pro-level (never team).
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
"starter" => (false, true, true, false),
"team" | "enterprise" => (true, true, true, true),
_ => (true, true, true, false),
// Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup,
// team_collaboration, cookie_bot, remote_interactive).
//
// pro and any unrecognized paid plan -> pro-level (never team). Solo is the
// one row where cookie_bot and browser_automation disagree, which is why
// cookie_bot can no longer be derived from browser_automation below.
let (
browser_automation,
cross_os_fingerprints,
cloud_backup,
team_collaboration,
cookie_bot,
remote_interactive,
) = match plan {
"solo" => (false, false, true, false, true, false),
"team" | "enterprise" => (true, true, true, true, true, true),
_ => (true, true, true, false, true, true),
};
Entitlements {
active,
@@ -94,9 +113,8 @@ fn derive_entitlements(
cross_os_fingerprints,
cloud_backup,
team_collaboration,
// A bot run IS remote automation on leased hardware, so the two capabilities
// never diverge: a plan that cannot drive a browser cannot warm one either.
cookie_bot: browser_automation,
cookie_bot,
remote_interactive,
profile_limit,
requests_per_hour: if browser_automation {
DEFAULT_REQUESTS_PER_HOUR
@@ -155,6 +173,13 @@ impl CloudUser {
/// locally from the plan fields (keeps older cached state / backends working).
pub fn entitlements(&self) -> Entitlements {
if let Some(e) = &self.entitlements {
// Returned verbatim, INCLUDING the `#[serde(default)]` false that a
// backend older than this release leaves on `cookie_bot` /
// `remote_interactive`. Repairing it here is impossible anyway — serde's
// default erases the difference between "sent false" and "not sent" — and
// it is not this layer's job: nothing in Rust gates on either flag, and
// `getEntitlements()` in `src/lib/entitlements.ts` fills both gaps at the
// single point every UI consumer already goes through.
return e.clone();
}
derive_entitlements(
@@ -794,6 +819,21 @@ impl CloudAuthManager {
}
/// Launch/drive profiles programmatically (local API + MCP automation).
/// Whether this account may run the nightly Cookie Bot.
///
/// NOT `can_use_browser_automation`. Solo is exactly the plan where the two
/// disagree — it pays for a nightly bot and has `browser_automation: false` —
/// so gating the bot on automation refused a Solo customer the one feature
/// their plan is sold on, and answered 402 while their scheduled runs kept
/// working server-side.
pub async fn can_use_cookie_bot(&self) -> bool {
self
.entitlements()
.await
.map(|e| e.cookie_bot)
.unwrap_or(false)
}
pub async fn can_use_browser_automation(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
+642 -11
View File
@@ -51,6 +51,20 @@ const REPORT_CODES: FailureCodes = FailureCodes {
conflict: cloud_errors::UNAVAILABLE,
};
/// Failure codes for the user-template routes.
///
/// Distinct from `SCHEDULE_CODES` on every axis that matters: a 404 here is a
/// template that was deleted (possibly from another device), not an unenrolled
/// profile, and a 409 is a name the user already used, not a teammate's
/// enrolment. Sharing the schedule set would have told someone renaming a site
/// list that a colleague already warms this profile.
const TEMPLATE_CODES: FailureCodes = FailureCodes {
bad_request: "COOKIE_BOT_INVALID_TEMPLATE_NAME",
forbidden: "COOKIE_BOT_NOT_ENTITLED",
not_found: "COOKIE_BOT_TEMPLATE_NOT_FOUND",
conflict: "COOKIE_BOT_TEMPLATE_NAME_TAKEN",
};
/// Every cookie-bot call fails as a code the frontend can translate.
///
/// There is no `Other(String)` carrying backend English: a raw message reaches
@@ -91,6 +105,18 @@ impl From<BackendFailure> for CookieBotError {
// One place for every request and response shape, so a backend contract change
// is a single edit here rather than a hunt through call sites.
/// One time-of-day an enrolment fires, on a set of local weekdays.
///
/// Copy, and deliberately tiny: a calendar is a list of these, and the desktop
/// rebuilds that list on every keystroke in the enrolment form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotSlot {
/// Bitmask of local weekdays, bit 0 = Monday. At least one bit set.
pub days_mask: u8,
/// Minutes past local midnight, in the schedule's timezone.
pub run_at_minute: u16,
}
/// A profile enrolled in the nightly bot.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotSchedule {
@@ -98,13 +124,31 @@ pub struct CookieBotSchedule {
pub profile_name: String,
pub platform: String,
pub enabled: bool,
/// Minutes past local midnight the run is anchored to.
/// Minutes past local midnight the FIRST slot is anchored to. The server
/// mirrors `slots[0]` onto this pair on every write.
pub run_at_minute: u16,
/// Bitmask of local weekdays, bit 0 = Monday.
/// The first slot's weekdays, bit 0 = Monday. See `run_at_minute`.
pub days_mask: u8,
/// Every time-of-day this enrolment fires.
///
/// `default` rather than required because a server older than multi-slot
/// scheduling sends only the mirrored pair above, and a decode failure there
/// would blank the whole Cookie Bot surface rather than show one time instead
/// of several. Callers must therefore fall back to the pair when this is
/// empty — never treat an empty list as "fires at no time".
#[serde(default)]
pub slots: Vec<CookieBotSlot>,
pub timezone: String,
/// Server-issued preset id. Opaque here — what it expands to is infra's.
pub preset: String,
/// The template the sites came from, or `None` for the user's own list.
///
/// A built-in id (`low-intent-purchaser`) means `sites` is EMPTY on purpose:
/// its URLs are server-owned and never sent to a client. A `user:<uuid>` id
/// is provenance only — those sites were copied onto the enrolment and are
/// present below.
#[serde(default)]
pub template_id: Option<String>,
pub max_minutes: u32,
#[serde(default)]
pub sites: Vec<String>,
@@ -120,6 +164,11 @@ pub struct CookieBotSchedule {
pub encrypted_sync: bool,
#[serde(default)]
pub has_proxy: bool,
/// Whether that exit is one a leased fleet host could dial. Defaults to false
/// on an older server that does not send it, which reads as "not reachable"
/// and is the safe direction.
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
pub touch_fingerprint: bool,
#[serde(default)]
@@ -162,8 +211,26 @@ pub struct CookieBotScheduleInput {
pub enabled: bool,
pub run_at_minute: u16,
pub days_mask: u8,
/// The whole calendar, when the caller has one.
///
/// `skip_serializing_if` is load-bearing rather than tidiness: the server
/// reads an ABSENT `slots` as "one slot, from the pair above" and refuses a
/// present-but-empty one, and `null` takes the refusing branch. Serialising
/// `None` as null would 400 every write from a single-slot form.
///
/// The pair above is still sent, mirrored from `slots[0]`, so a server that
/// predates multi-slot stores the first time rather than nothing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slots: Option<Vec<CookieBotSlot>>,
pub timezone: String,
pub preset: String,
/// A browsing template instead of a typed site list.
///
/// Mutually exclusive with a non-empty `sites`: the server refuses a write
/// carrying both, because merging a curated persona with the user's own list
/// produces neither. A caller naming a template sends `sites: []`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_id: Option<String>,
pub max_minutes: u32,
pub sites: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -191,6 +258,8 @@ pub struct CookieBotScheduleInput {
#[serde(default)]
pub has_proxy: bool,
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
pub encrypted_sync: bool,
#[serde(default)]
pub touch_fingerprint: bool,
@@ -331,6 +400,53 @@ pub struct CookieBotPreset {
pub description: Option<String>,
}
/// A server-owned browsing template: a named answer to "what is this profile
/// for", which the user picks INSTEAD of typing a site list.
///
/// Carries no URLs, and must not gain any. The pool a template draws from is
/// server-side for the same reason a preset's browsing model is: a published
/// list is one a retailer can filter, and each profile is given its own sample
/// so the template never becomes a fleet-wide fingerprint.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotTemplate {
pub id: String,
/// How many sites this template browses. Not which.
#[serde(default)]
pub site_count: u32,
/// Server-supplied English label and blurb, present only so a template added
/// after this build still renders. The UI prefers its own `t()` key for an id
/// it recognises.
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
/// The bounds the schedule routes actually enforce, as this build reads them.
///
/// Every field is optional because a server that predates `limits` sends none
/// of them, and a client that read a missing bound as `0` would refuse every
/// value the form can produce. Only the bounds the desktop acts on are decoded
/// — serde drops the rest, and this struct is what the GUI ultimately receives,
/// so adding a field here is what makes one reachable from TypeScript.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotLimits {
#[serde(default)]
pub min_minutes: Option<u32>,
#[serde(default)]
pub max_minutes: Option<u32>,
#[serde(default)]
pub min_sites: Option<u32>,
#[serde(default)]
pub max_sites: Option<u32>,
/// Most entries a calendar may carry.
#[serde(default)]
pub max_slots: Option<u32>,
/// Longest name a saved site list may be given.
#[serde(default)]
pub max_template_name_length: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotPresetList {
#[serde(default)]
@@ -339,6 +455,33 @@ pub struct CookieBotPresetList {
/// preference.
#[serde(default)]
pub default_preset: Option<String>,
/// The curated templates on offer. Served beside the presets so a template
/// added server-side appears without a desktop release.
#[serde(default)]
pub templates: Vec<CookieBotTemplate>,
/// The server's own bounds, when it publishes them. The desktop mirrors a
/// copy for offline form validation; these win where they disagree.
#[serde(default)]
pub limits: Option<CookieBotLimits>,
}
/// One of the caller's OWN saved site lists.
///
/// Carries its URLs, unlike {@link CookieBotTemplate} — they are the user's own
/// and there is nothing to withhold. Applying one copies the sites onto the
/// enrolment, so a list edited later does not silently change what an existing
/// enrolment browses until it is saved again.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotUserTemplate {
/// Already carries the `user:` prefix: this id's job is to be pasted into a
/// schedule's `template_id`, and assembling that convention on the client is
/// how the two kinds of template get confused.
pub id: String,
pub name: String,
#[serde(default)]
pub sites: Vec<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -465,7 +608,10 @@ pub struct CookieBotUsage {
/// the client cannot see — but a profile that can never qualify should never
/// reach a confirm dialog, an hour of quota or a leased host. Returns the
/// `{"code":…}` string a Tauri command surfaces directly.
pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
pub fn bot_precondition(
profile: &BrowserProfile,
exit: &crate::remote_exit::ExitReachability,
) -> Result<(), String> {
if !profile.is_sync_enabled() {
// The host materialises the profile by pulling it from donut-sync. A
// local-only profile has nothing there, so there is no path to a run.
@@ -491,6 +637,21 @@ pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
// than not warming it at all.
return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[]));
}
// ...and the exit has to be one the leased host can reach. The profile and its
// proxy record are pulled onto the fleet with no address rewriting, so
// 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH
// tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the
// run fails an hour has been leased and billed.
//
// Taken as an ARGUMENT rather than resolved here, for the same reason
// `ProfileState` is required rather than defaulted: resolving it needs the
// proxy and VPN stores, and a function that reaches into those globals is one
// no test can set up and every caller silently depends on. `exit_reachability`
// is the one place that resolution happens; this stays a pure predicate over
// facts it is handed.
if !exit.is_remote() {
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
}
Ok(())
}
@@ -511,6 +672,12 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
// A VPN is an exit node just as much as a proxy is; the server only asks
// whether the traffic leaves through something the user brought.
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
// ...and, separately, whether anyone OTHER than this machine could use it.
// `has_proxy` answers "did the user bring an exit"; this answers "is that
// exit an address a leased host can dial". They disagree for every local
// proxy, which is the case that used to be accepted and then fail on the
// fleet. See `remote_exit`.
proxy_remote_reachable: exit_reachability(profile).is_remote(),
// Always false: this data model has no mobile/touch profile. `resolved_os`
// yields only windows, macos or linux, and `bot_precondition` already
// refuses everything but the first two. Reported rather than omitted so the
@@ -533,16 +700,69 @@ pub struct ProfileState {
pub sync_enabled: bool,
pub encrypted_sync: bool,
pub has_proxy: bool,
/// Whether that exit is an address a leased fleet host can dial.
pub proxy_remote_reachable: bool,
pub touch_fingerprint: bool,
pub sticky_exit: bool,
}
/// Whether this profile's exit could be used from a host that is not this one.
///
/// Resolves the profile's proxy or VPN out of local storage — the server cannot
/// do this, because it never sees a proxy record until sync has uploaded one and
/// even then would have to re-derive what the browser will actually dial.
///
/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is
/// what the browser is pointed at, and it is the address the fleet has to reach.
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
if let Some(proxy_id) = profile.proxy_id.as_deref() {
let stored = crate::proxy_manager::PROXY_MANAGER
.get_stored_proxies()
.into_iter()
.find(|candidate| candidate.id == proxy_id);
return match stored {
Some(proxy) => classify_proxy(&proxy.proxy_settings),
// Referenced but missing. Fail closed: a dangling id is not evidence of a
// reachable exit, and the launch would fail anyway.
None => ExitReachability::Unknown {
reason: "the profile references a proxy that no longer exists".to_string(),
source: "proxy",
},
};
}
if let Some(vpn_id) = profile.vpn_id.as_deref() {
let config = crate::vpn::VPN_STORAGE
.lock()
.ok()
.and_then(|storage| storage.load_config(vpn_id).ok());
return match config {
Some(config) => match crate::vpn::parse_wireguard_config(&config.config_data) {
Ok(parsed) => classify_wireguard_endpoint(&parsed.peer_endpoint),
Err(error) => ExitReachability::Unknown {
reason: format!("VPN config could not be parsed ({error})"),
source: "VPN",
},
},
None => ExitReachability::Unknown {
reason: "the profile references a VPN config that no longer exists".to_string(),
source: "VPN",
},
};
}
ExitReachability::None
}
impl CookieBotScheduleInput {
/// Stamp the profile facts onto an input built from user-chosen values.
pub fn with_profile_state(mut self, state: ProfileState) -> Self {
self.sync_enabled = state.sync_enabled;
self.encrypted_sync = state.encrypted_sync;
self.has_proxy = state.has_proxy;
self.proxy_remote_reachable = state.proxy_remote_reachable;
self.touch_fingerprint = state.touch_fingerprint;
self.sticky_exit = state.sticky_exit;
self
@@ -663,6 +883,10 @@ pub async fn update_profile_state(
body.insert("sync_enabled".to_string(), state.sync_enabled.into());
body.insert("encrypted_sync".to_string(), state.encrypted_sync.into());
body.insert("has_proxy".to_string(), state.has_proxy.into());
body.insert(
"proxy_remote_reachable".to_string(),
state.proxy_remote_reachable.into(),
);
body.insert(
"touch_fingerprint".to_string(),
state.touch_fingerprint.into(),
@@ -855,6 +1079,171 @@ pub async fn list_presets() -> Result<CookieBotPresetList, CookieBotError> {
.await
}
// --- User-defined templates -------------------------------------------------
//
// The caller's own saved site lists. Unlike every other route in this file
// these are addressed by an id the SERVER minted and the client echoes back,
// so each one percent-encodes it: the id is spelled `user:<uuid>`, and a bare
// colon in a path segment is a spelling the router is free to read differently.
#[derive(Debug, Deserialize)]
struct UserTemplateListEnvelope {
#[serde(default)]
templates: Vec<CookieBotUserTemplate>,
}
#[derive(Debug, Deserialize)]
struct UserTemplateEnvelope {
template: CookieBotUserTemplate,
}
#[derive(Debug, Deserialize)]
struct UserTemplateDeleted {
#[serde(default)]
deleted: bool,
}
/// Every site list this user has saved, most recently edited first.
pub async fn list_user_templates() -> Result<Vec<CookieBotUserTemplate>, CookieBotError> {
let envelope: UserTemplateListEnvelope = request(
reqwest::Method::GET,
format!("{}/user-templates", base()),
Vec::new(),
None,
TEMPLATE_CODES,
)
.await?;
Ok(envelope.templates)
}
/// Save a new one.
pub async fn create_user_template(
name: &str,
sites: &[String],
) -> Result<CookieBotUserTemplate, CookieBotError> {
let body = serde_json::json!({ "name": name, "sites": sites });
let envelope: UserTemplateEnvelope = request(
reqwest::Method::POST,
format!("{}/user-templates", base()),
Vec::new(),
Some(body),
TEMPLATE_CODES,
)
.await?;
Ok(envelope.template)
}
/// Rename one, replace its sites, or both.
///
/// A PATCH with only the fields that changed, because the two are independent:
/// a rename that had to carry the whole site list is a rename that silently
/// reverts an edit made to it from another device in the meantime. Sending an
/// omitted field as `null` would defeat that, so each is skipped when absent.
pub async fn update_user_template(
id: &str,
name: Option<&str>,
sites: Option<&[String]>,
) -> Result<CookieBotUserTemplate, CookieBotError> {
let mut body = serde_json::Map::new();
if let Some(name) = name {
body.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
}
if let Some(sites) = sites {
body.insert("sites".to_string(), serde_json::json!(sites));
}
let envelope: UserTemplateEnvelope = request(
reqwest::Method::PATCH,
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
Vec::new(),
Some(serde_json::Value::Object(body)),
TEMPLATE_CODES,
)
.await?;
Ok(envelope.template)
}
/// Delete one. Enrolments that used it keep the sites they copied, so this is
/// never a way to stop a profile being warmed tonight.
///
/// Safe to repeat: deleting a list that is already gone answers `false` rather
/// than 404, which is what makes a retry after a dropped response harmless.
pub async fn delete_user_template(id: &str) -> Result<bool, CookieBotError> {
let deleted: UserTemplateDeleted = request(
reqwest::Method::DELETE,
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
Vec::new(),
None,
TEMPLATE_CODES,
)
.await?;
Ok(deleted.deleted)
}
// --- Tauri commands ---------------------------------------------------------
//
// The user-template commands live here rather than in `lib.rs` beside the
// schedule ones because they carry no local precondition: nothing about a saved
// site list depends on a profile this machine holds, so there is no profile to
// look up and no `bot_precondition` to apply. They must still be registered in
// `lib.rs`'s `invoke_handler` to be reachable.
/// Log a refusal and hand the frontend the envelope it translates.
///
/// The raw HTTP text never reaches the user: an untranslated backend sentence
/// in a Japanese UI is the failure the `{"code":…}` convention exists to stop.
fn command_error(context: &str, err: CookieBotError) -> String {
log::warn!(
"Cookie bot {context} failed: {} (HTTP {})",
err.code(),
err.status()
);
err.to_error_json()
}
/// Every site list this user has saved.
#[tauri::command]
pub async fn get_cookie_bot_user_templates() -> Result<Vec<CookieBotUserTemplate>, String> {
list_user_templates()
.await
.map_err(|e| command_error("template list", e))
}
/// Save the current site list under a name.
#[tauri::command]
pub async fn create_cookie_bot_user_template(
name: String,
sites: Vec<String>,
) -> Result<CookieBotUserTemplate, String> {
create_user_template(&name, &sites)
.await
.map_err(|e| command_error("template create", e))
}
/// Rename a saved list, replace its sites, or both. Omitted fields are left
/// exactly as they are.
#[tauri::command]
pub async fn update_cookie_bot_user_template(
id: String,
name: Option<String>,
sites: Option<Vec<String>>,
) -> Result<CookieBotUserTemplate, String> {
update_user_template(&id, name.as_deref(), sites.as_deref())
.await
.map_err(|e| command_error("template update", e))
}
/// Delete a saved list. `false` means there was nothing left to delete.
#[tauri::command]
pub async fn delete_cookie_bot_user_template(id: String) -> Result<bool, String> {
delete_user_template(&id)
.await
.map_err(|e| command_error("template delete", e))
}
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
pub async fn team_usage(period: Option<&str>) -> Result<CookieBotUsage, CookieBotError> {
let query = period
@@ -976,6 +1365,7 @@ async fn request<T: DeserializeOwned>(
mod tests {
use super::*;
use crate::profile::types::SyncMode;
use crate::remote_exit::ExitReachability;
fn eligible_profile() -> BrowserProfile {
BrowserProfile {
@@ -1045,7 +1435,8 @@ mod tests {
// that emptiness over the user's real profile.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Disabled;
let err = bot_precondition(&profile).expect_err("a local-only profile must be refused");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("a local-only profile must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_CLOUD_SYNC");
}
@@ -1055,7 +1446,8 @@ mod tests {
// one code cannot carry two different instructions.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Encrypted;
let err = bot_precondition(&profile).expect_err("encrypted sync must be refused");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("encrypted sync must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED");
}
@@ -1063,7 +1455,8 @@ mod tests {
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
let mut profile = eligible_profile();
profile.host_os = Some("linux".to_string());
let err = bot_precondition(&profile).expect_err("linux has no host to lease");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("linux has no host to lease");
let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope");
assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM");
assert_eq!(
@@ -1076,7 +1469,8 @@ mod tests {
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
let mut profile = eligible_profile();
profile.host_os = None;
let err = bot_precondition(&profile).expect_err("no OS means no matching host");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("no OS means no matching host");
assert_eq!(code_of(&err), "COOKIE_BOT_UNKNOWN_PLATFORM");
}
@@ -1087,7 +1481,8 @@ mod tests {
let mut profile = eligible_profile();
profile.proxy_id = None;
profile.vpn_id = None;
let err = bot_precondition(&profile).expect_err("datacenter egress must be refused");
let err = bot_precondition(&profile, &ExitReachability::None)
.expect_err("datacenter egress must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
}
@@ -1096,21 +1491,60 @@ mod tests {
let mut profile = eligible_profile();
profile.proxy_id = None;
profile.vpn_id = Some("vpn-1".to_string());
assert!(bot_precondition(&profile).is_ok());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
}
#[test]
fn a_windows_profile_with_sync_and_a_proxy_qualifies() {
let mut profile = eligible_profile();
profile.host_os = Some("windows".to_string());
assert!(bot_precondition(&profile).is_ok());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
}
#[test]
fn an_exit_only_this_machine_can_reach_is_refused() {
// The gap `has_proxy` alone could never see, and — before the verdict became
// an argument — a case no unit test could construct, because resolving it
// reached into the global proxy store. The profile is otherwise perfect.
let profile = eligible_profile();
let err = bot_precondition(
&profile,
&ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
source: "proxy",
},
)
.expect_err("a loopback exit cannot be dialled from a leased host");
// Its own code: "attach a proxy" is unactionable advice for someone whose
// proxy is plainly attached.
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
#[test]
fn an_exit_we_could_not_read_is_refused_too() {
// Fails closed. Refusing a working setup costs one support question;
// accepting a broken one burns a leased hour and damages an identity.
let err = bot_precondition(
&eligible_profile(),
&ExitReachability::Unknown {
reason: "VPN config could not be parsed".to_string(),
source: "VPN",
},
)
.expect_err("an unreadable exit must not be assumed reachable");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
/// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView`
/// in donutbrowser-infra's `cookie-bot.service.ts` builds it.
const SERVER_SCHEDULE_VIEW: &str = r#"{
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
"run_at_minute":120,"days_mask":127,"timezone":"Europe/Berlin",
"run_at_minute":120,"days_mask":127,
"slots":[{"days_mask":127,"run_at_minute":120},{"days_mask":31,"run_at_minute":690}],
"timezone":"Europe/Berlin","template_id":null,
"preset":"balanced","max_minutes":45,"sites":["https://example.com"],
"jitter_seconds":900,"sync_enabled":true,"encrypted_sync":false,
"has_proxy":true,"touch_fingerprint":false,"sticky_exit":false,
@@ -1142,6 +1576,132 @@ mod tests {
assert!(schedule.blocked_by.is_none());
}
#[test]
fn a_schedule_carries_its_whole_calendar_not_just_the_first_time() {
// The mirrored pair is `slots[0]`, so a client that read only the pair
// would show "every night at 02:00" for an enrolment that also runs at
// 11:30 on weeknights — fewer runs than the user booked, silently.
let schedule: CookieBotSchedule =
serde_json::from_str(SERVER_SCHEDULE_VIEW).expect("a multi-slot schedule must deserialize");
assert_eq!(schedule.slots.len(), 2);
assert_eq!(schedule.slots[0].run_at_minute, schedule.run_at_minute);
assert_eq!(schedule.slots[0].days_mask, schedule.days_mask);
assert_eq!(schedule.slots[1].run_at_minute, 690);
assert_eq!(schedule.slots[1].days_mask, 31);
}
#[test]
fn a_server_that_predates_multi_slot_still_decodes_with_no_slots() {
// `slots` absent is a deployment that has not rolled forward, not a broken
// enrolment. Requiring it would blank the whole Cookie Bot surface against
// an older backend rather than show the one time it does know about.
let schedule: CookieBotSchedule = serde_json::from_str(
r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":true,
"run_at_minute":120,"days_mask":31,"timezone":"UTC","preset":"light",
"max_minutes":10}"#,
)
.expect("a pre-multi-slot schedule must deserialize");
assert!(schedule.slots.is_empty());
assert!(schedule.template_id.is_none());
}
#[test]
fn a_templated_enrolment_reports_its_template_and_no_sites() {
// A built-in template's URLs are server-owned. An empty `sites` here is the
// contract working, not a schedule with nothing to browse — anything that
// reads it as "no sites" would show a healthy enrolment as broken.
let schedule: CookieBotSchedule = serde_json::from_str(
&SERVER_SCHEDULE_VIEW
.replace(
"\"template_id\":null",
"\"template_id\":\"low-intent-purchaser\"",
)
.replace("\"sites\":[\"https://example.com\"]", "\"sites\":[]"),
)
.expect("a templated schedule must deserialize");
assert_eq!(
schedule.template_id.as_deref(),
Some("low-intent-purchaser")
);
assert!(schedule.sites.is_empty());
assert!(schedule.blocked_by.is_none());
}
#[test]
fn a_calendar_is_sent_as_slots_and_omitted_entirely_when_there_is_none() {
// The server reads an ABSENT `slots` as "one slot, from the legacy pair"
// and REFUSES a null or empty one. Serialising `None` as null would 400
// every write from a form with a single time on it.
let one_slot = CookieBotScheduleInput {
profile_name: "Yu".to_string(),
platform: "macos".to_string(),
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "Europe/Berlin".to_string(),
preset: "balanced".to_string(),
max_minutes: 45,
sites: vec!["https://example.com".to_string()],
..Default::default()
};
let encoded = serde_json::to_value(&one_slot).expect("input must serialize");
assert!(
encoded.get("slots").is_none(),
"an absent calendar must be absent on the wire, not null"
);
assert!(encoded.get("template_id").is_none());
let many = CookieBotScheduleInput {
slots: Some(vec![
CookieBotSlot {
days_mask: 127,
run_at_minute: 120,
},
CookieBotSlot {
days_mask: 31,
run_at_minute: 690,
},
]),
..one_slot
};
let encoded = serde_json::to_value(&many).expect("input must serialize");
let slots = encoded["slots"].as_array().expect("slots must be a list");
assert_eq!(slots.len(), 2);
// Mirrored, because a server that predates multi-slot ignores `slots` and
// stores this pair. Dropping it would leave that server with no time at all.
assert_eq!(encoded["run_at_minute"], 120);
assert_eq!(encoded["days_mask"], 127);
}
#[test]
fn a_templated_write_names_the_template_and_sends_no_sites() {
// The server refuses a body carrying both: a curated persona merged with
// the user's own list is neither.
let input = CookieBotScheduleInput {
profile_name: "Yu".to_string(),
platform: "macos".to_string(),
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "UTC".to_string(),
preset: "balanced".to_string(),
max_minutes: 45,
sites: Vec::new(),
template_id: Some("low-intent-purchaser".to_string()),
..Default::default()
};
let encoded = serde_json::to_value(&input).expect("input must serialize");
assert_eq!(encoded["template_id"], "low-intent-purchaser");
assert_eq!(
encoded["sites"].as_array().map(Vec::len),
Some(0),
"sites must still be sent, and must be empty, beside a template"
);
}
#[test]
fn a_broken_enrolment_carries_the_reason_it_cannot_run() {
// The whole point of `blocked_by`: a profile whose proxy was detached in
@@ -1419,5 +1979,76 @@ mod tests {
assert_eq!(presets.presets[0].id, "balanced");
assert_eq!(presets.presets[0].typical_minutes, Some(35));
assert_eq!(presets.default_preset.as_deref(), Some("balanced"));
// An older deployment sends neither of these, and the dialog has to render
// against it: no templates simply means the picker offers the user's own
// list, and no limits means the mirrored bounds apply.
assert!(presets.templates.is_empty());
assert!(presets.limits.is_none());
}
#[test]
fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() {
// The pool is server-owned for the same reason a preset's browsing model
// is. If this type ever gained a `sites` field the curation would be
// published, and a published list is one a retailer can filter.
let presets: CookieBotPresetList = serde_json::from_str(
r#"{"presets":[],"default_preset":"balanced",
"templates":[{"id":"low-intent-purchaser","site_count":32,
"name":"Low-Intent Purchaser","description":"Price-sensitive browsing."}],
"limits":{"min_minutes":5,"max_minutes":120,"min_sites":1,"max_sites":40,
"max_site_length":2048,"max_jitter_seconds":3600,"max_slots":14,
"max_template_name_length":80}}"#,
)
.expect("the preset list must carry templates and limits");
assert_eq!(presets.templates[0].id, "low-intent-purchaser");
assert_eq!(presets.templates[0].site_count, 32);
let limits = presets.limits.expect("limits must decode");
assert_eq!(limits.max_slots, Some(14));
assert_eq!(limits.max_template_name_length, Some(80));
assert_eq!(limits.max_sites, Some(40));
}
#[test]
fn a_saved_list_arrives_with_the_prefix_a_schedule_write_needs() {
// The id is what `template_id` takes verbatim. Handing the client a bare
// uuid and expecting it to prepend `user:` is how a saved list gets looked
// up against the built-in catalogue instead — which answers "no sites" and
// silently unschedules the profile.
let envelope: UserTemplateListEnvelope = serde_json::from_str(
r#"{"templates":[{"id":"user:1c9a","name":"My shops",
"sites":["https://example.com"],"updated_at":"2026-08-05T10:00:00.000Z"}]}"#,
)
.expect("the user template list must deserialize");
let template = &envelope.templates[0];
assert!(template.id.starts_with("user:"));
assert_eq!(template.name, "My shops");
assert_eq!(template.sites.len(), 1);
}
#[test]
fn deleting_a_saved_list_that_is_already_gone_is_not_a_failure() {
// The route never 404s, so a delete retried after a dropped response has to
// read as "nothing left to do" rather than as an error the user must act on.
let deleted: UserTemplateDeleted =
serde_json::from_str(r#"{"deleted":false,"id":"user:gone"}"#)
.expect("a no-op delete must deserialize");
assert!(!deleted.deleted);
}
#[test]
fn a_template_404_is_a_missing_list_and_not_an_unenrolled_profile() {
// Sharing SCHEDULE_CODES here would tell someone renaming a site list that
// their profile is not enrolled, and a name collision that a teammate
// already warms the profile.
assert_eq!(
cloud_errors::classify_message("(404) Not Found", TEMPLATE_CODES).code,
"COOKIE_BOT_TEMPLATE_NOT_FOUND"
);
assert_eq!(
cloud_errors::classify_message("(409) Conflict", TEMPLATE_CODES).code,
"COOKIE_BOT_TEMPLATE_NAME_TAKEN"
);
}
}
+20 -2
View File
@@ -18,8 +18,13 @@ impl DefaultBrowser {
#[cfg(target_os = "windows")]
return windows::is_default_browser();
// Linux answers this by running `xdg-mime`, a shell script that forks
// further. That is blocking work with no upper bound, and this command
// runs on the same async runtime as every other command, the REST API and
// the sync scheduler — so doing it inline occupies a worker for as long as
// the desktop takes to answer. The Settings page polls this on a timer.
#[cfg(target_os = "linux")]
return linux::is_default_browser();
return blocking(linux::is_default_browser).await;
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Err("Unsupported platform".to_string())
@@ -32,14 +37,27 @@ impl DefaultBrowser {
#[cfg(target_os = "windows")]
return windows::set_as_default_browser();
// Same reasoning, and this one additionally sleeps 500ms before verifying.
#[cfg(target_os = "linux")]
return linux::set_as_default_browser();
return blocking(linux::set_as_default_browser).await;
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Err("Unsupported platform".to_string())
}
}
/// Run blocking work off the async runtime's worker threads.
#[cfg(target_os = "linux")]
async fn blocking<T, F>(work: F) -> Result<T, String>
where
F: FnOnce() -> Result<T, String> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(work)
.await
.map_err(|e| format!("Default browser check did not run: {e}"))?
}
#[cfg(target_os = "macos")]
mod macos {
use core_foundation::base::OSStatus;
+153 -34
View File
@@ -59,25 +59,48 @@ impl BlocklistLevel {
}
}
pub fn url(&self) -> Option<&'static str> {
/// Where this tier's `domains/*.txt` list is fetched from.
///
/// `raw.githubusercontent.com` only, deliberately. This used to be a jsDelivr
/// URL against `hagezi/dns-blocklists`, and it broke every blocklisted launch:
/// that repo grew past jsDelivr's 150 MB package-resolution limit, so
/// `@latest` began answering `403 Package size exceeded the configured limit
/// of 150 MB` for every tier. Nothing was wrong locally and nothing a user
/// could do would fix it — a third party's repo got too big and a CDN's
/// package resolver gave up.
///
/// raw.githubusercontent.com serves the file straight from the ref and
/// resolves no package at all, so it cannot fail that way. The
/// `domains/*.txt` format now lives in `hagezi/dns-blocklists-legacy`.
///
/// Returned as a slice so the fetch path can try several sources if one is
/// ever added; today there is exactly one on purpose.
pub fn urls(&self) -> &'static [&'static str] {
match self {
Self::None | Self::Custom => None,
Self::None | Self::Custom => &[],
Self::Light => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/light.txt"]
}
Self::Normal => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt")
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/multi.txt"]
}
Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"),
Self::ProPlus => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt")
}
Self::Ultimate => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt")
Self::Pro => {
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.txt"]
}
Self::ProPlus => &[
"https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.plus.txt",
],
Self::Ultimate => &[
"https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/ultimate.txt",
],
}
}
/// The preferred source, for callers that only need to name one.
pub fn url(&self) -> Option<&'static str> {
self.urls().first().copied()
}
pub fn filename(&self) -> Option<&'static str> {
match self {
Self::None => None,
@@ -295,49 +318,85 @@ impl BlocklistManager {
}
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
let production_url = level
.url()
.ok_or_else(|| format!("No URL for level {:?}", level))?;
let production_urls: Vec<String> = level.urls().iter().map(|u| (*u).to_string()).collect();
if production_urls.is_empty() {
return Err(format!("No URL for level {:?}", level));
}
#[cfg(feature = "e2e")]
let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
let urls = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
.ok()
.filter(|base| !base.is_empty())
.map(|base| {
format!(
vec![format!(
"{}/{}",
base.trim_end_matches('/'),
level.filename().unwrap_or("blocklist.txt")
)
)]
})
.unwrap_or_else(|| production_url.to_string());
.unwrap_or(production_urls);
#[cfg(not(feature = "e2e"))]
let url = production_url.to_string();
let urls = production_urls;
let path =
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
let cache_dir = Self::cache_dir();
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
log::info!(
"[dns-blocklist] Fetching {} from {}",
level.display_name(),
url
);
// Try each source in turn. A tier is only a failure once EVERY source has
// refused it: the outage this replaced was one CDN answering 403 for a
// reason that had nothing to do with the user, and falling back would have
// made it invisible.
let mut body: Option<String> = None;
let mut failures: Vec<String> = Vec::new();
let response = HTTP_CLIENT
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
for url in &urls {
log::info!(
"[dns-blocklist] Fetching {} from {}",
level.display_name(),
url
);
if !response.status().is_success() {
return Err(format!("HTTP {} when fetching {}", response.status(), url));
let response = match HTTP_CLIENT.get(url).send().await {
Ok(response) => response,
Err(e) => {
failures.push(format!("{url}: {e}"));
continue;
}
};
if !response.status().is_success() {
failures.push(format!("{url}: HTTP {}", response.status()));
continue;
}
match response.text().await {
Ok(text) => {
if failures.is_empty() {
log::info!("[dns-blocklist] {} fetched", level.display_name());
} else {
// Worth saying out loud: the primary source is down and somebody
// should know before the backup goes too.
log::warn!(
"[dns-blocklist] {} came from a fallback source after {} failure(s): {}",
level.display_name(),
failures.len(),
failures.join("; ")
);
}
body = Some(text);
break;
}
Err(e) => failures.push(format!("{url}: {e}")),
}
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response body: {e}"))?;
let Some(body) = body else {
return Err(format!(
"Failed to fetch blocklist {} from any source ({})",
level.display_name(),
failures.join("; ")
));
};
// Write atomically: write to temp file, then rename
let tmp_path = path.with_extension("tmp");
@@ -796,6 +855,66 @@ mod tests {
assert!(BlocklistLevel::None.filename().is_none());
}
#[test]
fn every_tier_is_served_only_from_raw_githubusercontent() {
// jsDelivr is deliberately not a source. It resolves a whole package to
// serve one file, so when `hagezi/dns-blocklists` grew past its 150 MB
// limit every tier began answering 403 — an outage nothing local could fix.
// raw.githubusercontent.com serves the file straight from the ref and
// resolves no package, so it cannot fail that way.
for &level in BlocklistLevel::all_downloadable() {
let urls = level.urls();
assert_eq!(
urls.len(),
1,
"{} should have exactly one source: {urls:?}",
level.as_str()
);
for url in urls {
assert!(
url.starts_with("https://raw.githubusercontent.com/"),
"{} must be served from raw.githubusercontent.com: {url}",
level.as_str()
);
assert!(
!url.contains("jsdelivr"),
"{} must not reintroduce jsDelivr: {url}",
level.as_str()
);
}
}
}
#[test]
fn no_tier_points_at_the_oversized_upstream_repo() {
// The `domains/*.txt` format moved to `-legacy`, which is small enough for
// jsDelivr to resolve. Pointing any tier back at the original repo
// reintroduces the 403.
for &level in BlocklistLevel::all_downloadable() {
for url in level.urls() {
assert!(
!url.contains("/hagezi/dns-blocklists@") && !url.contains("/hagezi/dns-blocklists/"),
"{} still points at the oversized repo: {url}",
level.as_str()
);
assert!(
url.contains("dns-blocklists-legacy"),
"{} should read the legacy list repo: {url}",
level.as_str()
);
assert!(
url.ends_with(
level
.filename()
.expect("downloadable tiers have a filename")
),
"{} source must serve its own tier file: {url}",
level.as_str()
);
}
}
}
#[test]
fn test_cache_status_returns_all_levels() {
let statuses = BlocklistManager::get_cache_status();
+159 -19
View File
@@ -26,6 +26,29 @@ pub struct DownloadedBrowsersRegistry {
geoip_downloader: &'static GeoIPDownloader,
}
/// Filename suffixes that identify a *downloaded artifact* — the container we
/// fetched from the network — rather than a file belonging to the extracted
/// install. Cleanup preserves these so a manually placed archive survives.
///
/// `.exe` and `.AppImage` are deliberately absent even though both can be
/// downloaded. On Windows the extracted Wayfern payload is flat at the version
/// root (`extraction::ensure_correct_directory_structure` returns early rather
/// than nesting it), so preserving `.exe` kept `chrome.exe` while deleting every
/// sibling `.dll`, the `.manifest`, `.pak` and `locales/` — a gutted install
/// that then failed to launch with os error 14001. On Linux the `.AppImage`
/// *is* the extracted payload. Cleanup must never leave behind something that
/// still reads as an installed browser; the archive is deleted right after a
/// successful download anyway, so nothing of value is lost.
const DOWNLOAD_ARTIFACT_SUFFIXES: [&str; 7] =
["zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "pkg", "msi"];
fn is_download_artifact(file_name: &str) -> bool {
let lowered = file_name.to_lowercase();
DOWNLOAD_ARTIFACT_SUFFIXES
.iter()
.any(|suffix| lowered.ends_with(suffix))
}
impl DownloadedBrowsersRegistry {
fn new() -> Self {
Self {
@@ -174,15 +197,19 @@ impl DownloadedBrowsersRegistry {
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Never delete files out from under a live download or extraction. Both the
// detached task that runs the moment a download completes and the periodic
// maintenance task land here, and a freshly downloaded version is referenced
// by no persisted profile while profile creation is still in flight.
if crate::downloader::is_downloading(browser, version) {
log::info!("Skipping cleanup of {browser} {version}: a download is in progress");
return Ok(());
}
if let Some(info) = self.remove_browser(browser, version) {
// Clean up extracted binaries but preserve downloaded archives
if info.file_path.exists() {
if info.file_path.is_dir() {
// Allowed archive extensions to preserve
let archive_exts = [
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
];
for entry in fs::read_dir(&info.file_path)? {
let entry = entry?;
let path = entry.path();
@@ -192,16 +219,11 @@ impl DownloadedBrowsersRegistry {
continue;
}
// For files, preserve if they look like downloaded archives/installers
// For files, preserve only genuine downloaded archives/installers
let keep = path
.file_name()
.and_then(|n| n.to_str())
.map(|name| {
// Match suffixes (handles multi-part extensions like .tar.xz)
archive_exts
.iter()
.any(|ext| name.to_lowercase().ends_with(&ext.to_lowercase()))
})
.map(is_download_artifact)
.unwrap_or(false);
if !keep {
@@ -215,13 +237,7 @@ impl DownloadedBrowsersRegistry {
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let archive_exts = [
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
];
let is_archive = archive_exts
.iter()
.any(|ext| file_name.to_lowercase().ends_with(&ext.to_lowercase()));
if !is_archive {
if !is_download_artifact(file_name) {
fs::remove_file(&info.file_path)?;
}
}
@@ -1230,6 +1246,130 @@ mod tests {
);
}
/// The Windows payload is extracted flat at the version root, so preserving
/// every `*.exe` used to leave `chrome.exe` behind while deleting the `.dll`
/// files and the `.manifest` next to it. That gutted directory still passed
/// the "is it downloaded?" check, was re-registered as healthy, and launching
/// it failed in the Windows loader with os error 14001.
#[test]
fn test_cleanup_removes_the_browser_executable_not_just_its_libraries() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let version_dir = temp.path().join("wayfern").join("140.0");
std::fs::create_dir_all(&version_dir).unwrap();
for name in [
"chrome.exe",
"wayfern.exe",
"notification_helper.exe",
"chrome.dll",
"chrome_elf.dll",
"chrome.exe.manifest",
"resources.pak",
] {
std::fs::File::create(version_dir.join(name)).unwrap();
}
std::fs::create_dir_all(version_dir.join("locales")).unwrap();
let registry = DownloadedBrowsersRegistry::new();
registry.add_browser(DownloadedBrowserInfo {
browser: "wayfern".to_string(),
version: "140.0".to_string(),
file_path: version_dir.clone(),
});
registry
.cleanup_failed_download("wayfern", "140.0")
.expect("cleanup should succeed");
let leftovers: Vec<String> = std::fs::read_dir(&version_dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(
leftovers.is_empty(),
"cleanup must not leave a half-deleted install behind, found: {leftovers:?}"
);
}
/// The preserve rule still exists for its actual purpose: a downloaded
/// archive (including one placed there by hand) survives the cleanup.
#[test]
fn test_cleanup_preserves_a_downloaded_archive() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let version_dir = temp.path().join("wayfern").join("141.0");
std::fs::create_dir_all(&version_dir).unwrap();
std::fs::File::create(version_dir.join("wayfern-win64.zip")).unwrap();
std::fs::File::create(version_dir.join("wayfern-mac.tar.xz")).unwrap();
std::fs::File::create(version_dir.join("chrome.exe")).unwrap();
std::fs::File::create(version_dir.join("chrome.dll")).unwrap();
let registry = DownloadedBrowsersRegistry::new();
registry.add_browser(DownloadedBrowserInfo {
browser: "wayfern".to_string(),
version: "141.0".to_string(),
file_path: version_dir.clone(),
});
registry
.cleanup_failed_download("wayfern", "141.0")
.expect("cleanup should succeed");
assert!(
version_dir.join("wayfern-win64.zip").exists(),
"a downloaded archive must be preserved"
);
assert!(
version_dir.join("wayfern-mac.tar.xz").exists(),
"multi-part archive extensions must still be recognised"
);
assert!(
!version_dir.join("chrome.exe").exists(),
"the extracted executable must be removed"
);
assert!(
!version_dir.join("chrome.dll").exists(),
"the extracted libraries must be removed"
);
}
/// Cleanup runs on a detached task the moment a download completes and again
/// on a periodic timer, either of which can land while an install is still
/// being written. It must stand down instead of deleting live files.
#[test]
fn test_cleanup_stands_down_while_a_download_is_in_progress() {
use tempfile::TempDir;
let temp = TempDir::new().unwrap();
let version_dir = temp.path().join("wayfern").join("142.0");
std::fs::create_dir_all(&version_dir).unwrap();
std::fs::File::create(version_dir.join("chrome.exe")).unwrap();
std::fs::File::create(version_dir.join("chrome.dll")).unwrap();
let registry = DownloadedBrowsersRegistry::new();
registry.add_browser(DownloadedBrowserInfo {
browser: "wayfern".to_string(),
version: "142.0".to_string(),
file_path: version_dir.clone(),
});
crate::downloader::mark_downloading_for_test("wayfern", "142.0");
let result = registry.cleanup_failed_download("wayfern", "142.0");
crate::downloader::clear_download_state_for_browser("wayfern");
result.expect("cleanup should succeed");
assert!(
version_dir.join("chrome.exe").exists() && version_dir.join("chrome.dll").exists(),
"an in-flight download must not be deleted out from under itself"
);
assert!(
registry.is_browser_registered("wayfern", "142.0"),
"the registry entry must survive too, the version is still being installed"
);
}
#[test]
fn test_is_browser_registered_vs_downloaded() {
let registry = DownloadedBrowsersRegistry::new();
+11
View File
@@ -879,6 +879,17 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
downloading.contains(&download_key)
}
/// Test-only: mark a browser-version pair as in flight so guards that consult
/// `is_downloading` can be exercised without running a real download. Clear it
/// again with `clear_download_state_for_browser`.
#[cfg(test)]
pub fn mark_downloading_for_test(browser: &str, version: &str) {
DOWNLOADING_BROWSERS
.lock()
.unwrap()
.insert(format!("{browser}-{version}"));
}
/// Clear all in-progress download bookkeeping for a browser.
///
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
+58 -30
View File
@@ -86,6 +86,62 @@ fn find_zip_start(data: &[u8]) -> usize {
0
}
/// Read and parse an extension archive's `manifest.json`. Handles the CRX3
/// header by seeking to the embedded ZIP. Shared with
/// `vpn_extension_detect`, which classifies from the raw manifest rather than
/// from the metadata subset persisted on `Extension`.
pub(crate) fn read_manifest_from_archive(
file_data: &[u8],
file_type: &str,
) -> Option<serde_json::Value> {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut contents = String::new();
{
let mut file = archive.by_name("manifest.json").ok()?;
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
}
serde_json::from_str(&contents).ok()
}
/// Resolve a `__MSG_key__` placeholder against the archive's default locale
/// messages. Chromium extensions routinely localize `name`/`description`, and
/// showing the raw placeholder in a warning dialog reads as a bug.
pub(crate) fn resolve_archive_i18n(
file_data: &[u8],
file_type: &str,
manifest: &serde_json::Value,
value: &str,
) -> Option<String> {
let key = crate::vpn_extension_detect::message_placeholder_key(value)?;
let default_locale = manifest.get("default_locale")?.as_str()?;
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut contents = String::new();
{
let mut file = archive
.by_name(&format!("_locales/{default_locale}/messages.json"))
.ok()?;
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
}
let messages: serde_json::Value = serde_json::from_str(&contents).ok()?;
crate::vpn_extension_detect::lookup_message(&messages, &key)
}
#[allow(clippy::type_complexity)]
fn extract_manifest_metadata(
file_data: &[u8],
@@ -97,39 +153,11 @@ fn extract_manifest_metadata(
Option<String>,
Option<String>,
) {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
let mut archive = match zip::ZipArchive::new(cursor) {
Ok(a) => a,
Err(_) => return (None, None, None, None, None),
};
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
let mut contents = String::new();
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
Some(contents)
} else {
None
}
} else {
None
};
let manifest_content = match manifest_content {
Some(c) => c,
let manifest = match read_manifest_from_archive(file_data, file_type) {
Some(v) => v,
None => return (None, None, None, None, None),
};
let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
Ok(v) => v,
Err(_) => return (None, None, None, None, None),
};
let name = manifest
.get("name")
.and_then(|v| v.as_str())
+417 -147
View File
@@ -1,11 +1,15 @@
//! Launch-time consistency check: resolve the proxy's exit IP, geolocate it
//! with the bundled MaxMind database (the same source the fingerprint generator
//! uses), then compare its timezone and country against the profile
//! fingerprint's timezone and language. A mismatch (e.g. a US fingerprint
//! behind a German exit IP) is a strong anti-bot tell even though the real
//! device never leaks — so we warn the user after launch and offer to match the
//! fingerprint to the exit. Launches never rewrite the fingerprint silently, so
//! a real mismatch always surfaces here.
//! Measures a proxy's exit node and compares it to a profile's fingerprint.
//!
//! Resolve the exit IP through the upstream, geolocate it with the bundled
//! MaxMind database (the same source the fingerprint generator uses), then
//! compare its timezone and country against the fingerprint's timezone and
//! language. A mismatch (e.g. a US fingerprint behind a German exit IP) is a
//! strong anti-bot tell even though the real device never leaks.
//!
//! This module only measures. Deciding what a mismatch *means* for a launch —
//! block, warn, or ignore — belongs to `launch_gate`, which calls
//! `probe_and_check_consistency` before the browser is spawned. Launches never
//! rewrite the fingerprint silently, so a real mismatch always surfaces.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -19,20 +23,68 @@ use crate::proxy_manager::PROXY_MANAGER;
/// on every launch is wasteful.
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
/// Ceiling on a single exit probe. `fetch_public_ip` races six endpoints with
/// a 10s timeout each, which is fine for a background check but far longer
/// than a user will wait staring at a launch that has not started yet.
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
#[derive(Clone)]
struct CachedExit {
fetched_at: u64,
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
/// The endpoint this exit was measured through. Editing a stored proxy keeps
/// its id, so without this an entry outlives the endpoint it describes: the
/// check would compare a re-generated fingerprint against the *old* exit and
/// either warn about a correct profile or — worse — call a genuinely
/// mismatched one consistent, which is exactly the tell it exists to catch.
proxy_url: String,
///
/// Never a loopback URL: the Xray/VPN workers a launch spins up get a fresh
/// random port and credentials each time, so keying on those would miss on
/// every relaunch and re-probe forever.
identity: String,
timezone: Option<String>,
country_code: Option<String>,
ip: Option<String>,
}
/// Identity of the exit a profile routes through, stable across worker
/// restarts.
///
/// `scope` keys the cache; `identity` detects that the endpoint behind that
/// key changed. Cloud-derived proxies inject a per-profile sticky-session id,
/// so two profiles sharing one stored proxy correctly get different identities
/// and never inherit each other's verdict.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExitCacheKey {
pub scope: String,
pub identity: String,
}
/// Resolve the cache identity from the profile's *stored* configuration.
///
/// Deliberately not derived from the normalized upstream the launcher passes
/// to the probe: for VLESS and VPN that upstream is a loopback worker whose
/// port and credentials are regenerated per launch.
pub fn exit_cache_key(profile: &BrowserProfile) -> Option<ExitCacheKey> {
if let Some(proxy_id) = &profile.proxy_id {
let settings = PROXY_MANAGER
.resolve_proxy_for_profile(proxy_id, &profile.id.to_string())
.or_else(|| PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))?;
// build_proxy_url returns the VLESS URI verbatim for vless proxies, so one
// call covers every transport.
return Some(ExitCacheKey {
scope: format!("proxy:{proxy_id}"),
identity: crate::proxy_manager::ProxyManager::build_proxy_url(&settings),
});
}
if let Some(vpn_id) = &profile.vpn_id {
return Some(ExitCacheKey {
scope: format!("vpn:{vpn_id}"),
identity: vpn_id.clone(),
});
}
None
}
lazy_static::lazy_static! {
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
}
@@ -54,7 +106,7 @@ pub struct ConsistencyResult {
}
impl ConsistencyResult {
fn skip() -> Self {
pub fn skip() -> Self {
Self {
consistent: true,
checked: false,
@@ -68,18 +120,15 @@ impl ConsistencyResult {
}
}
/// URL for handing this proxy to reqwest. VLESS is reached through the
/// authenticated loopback Xray-core worker already serving the profile.
fn proxy_url(settings: &crate::browser::ProxySettings, profile_id: Option<&str>) -> Option<String> {
/// Whether this upstream can carry a probe request at all.
///
/// Shadowsocks and anything else reqwest cannot dial directly is skipped
/// rather than guessed at.
fn probe_url(settings: &crate::browser::ProxySettings) -> Option<String> {
match settings.proxy_type.to_lowercase().as_str() {
"http" | "https" | "socks4" | "socks5" => Some(
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
crate::proxy_manager::ProxyManager::build_probe_proxy_url(settings),
),
"vless" => profile_id
.and_then(crate::xray_worker_storage::find_xray_worker_by_profile_id)
.map(|worker| {
crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings())
}),
_ => None,
}
}
@@ -119,120 +168,174 @@ fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<Strin
(timezone, language)
}
/// Run the check for a profile. No-ops (consistent, unchecked) when the
/// profile has no proxy or the exit node can't be reached.
pub async fn check_profile_consistency(
/// A mutex whose poison is not fatal.
///
/// A panic anywhere under this lock used to brick the check process-wide.
/// That was tolerable when a failed check only skipped a warning; now a launch
/// consults it, so a poisoned lock must degrade rather than propagate.
fn exit_cache() -> std::sync::MutexGuard<'static, HashMap<String, CachedExit>> {
EXIT_CACHE.lock().unwrap_or_else(|e| e.into_inner())
}
/// Compare a measured exit against a profile's fingerprint. Pure — no I/O.
pub fn compare_exit_to_fingerprint(
profile: &BrowserProfile,
) -> Result<ConsistencyResult, String> {
let Some(proxy_id) = &profile.proxy_id else {
return Ok(ConsistencyResult::skip());
};
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
return Ok(ConsistencyResult::skip());
};
let profile_id = profile.id.to_string();
let Some(url) = proxy_url(&settings, Some(&profile_id)) else {
return Ok(ConsistencyResult::skip());
};
let cache_identity = if settings.proxy_type.eq_ignore_ascii_case("vless") {
settings.vless_uri.clone().unwrap_or_else(|| url.clone())
} else {
url.clone()
};
let now = crate::proxy_manager::now_secs();
// Serve a fresh cached exit lookup for this proxy if we have one, but only if
// it was measured through the proxy's current endpoint and credentials.
let cached = {
let cache = EXIT_CACHE.lock().unwrap();
cache
.get(proxy_id)
.filter(|c| {
c.proxy_url == cache_identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
})
.cloned()
};
let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached {
(c.timezone, c.country_code, c.ip)
} else {
// Resolve the exit IP through the proxy, then geolocate it with the SAME
// bundled MaxMind database the fingerprint generator (and the on-demand
// match) use. Using one geo source everywhere means the check can never
// disagree with what generation produced — a second source (e.g. ip-api)
// routinely reports a different IANA zone for the same IP in multi-zone
// countries, which would flag correctly-generated fingerprints and would
// leave the "match to proxy" fix unable to satisfy the check.
let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url))
.await
.map_err(|e| format!("exit-node lookup failed: {e}"))?;
match crate::geolocation::get_geolocation(&exit_ip) {
Ok(geo) => {
let tz = Some(geo.timezone);
let cc = geo.locale.region.clone();
let ip = Some(exit_ip);
EXIT_CACHE.lock().unwrap().insert(
proxy_id.clone(),
CachedExit {
fetched_at: now,
proxy_url: cache_identity,
timezone: tz.clone(),
country_code: cc.clone(),
ip: ip.clone(),
},
);
(tz, cc, ip)
}
// Reached the exit but couldn't place it (database missing, or a private
// exit IP). Skip rather than warn on an unknown location — the same
// database gates fingerprint geo, so there's nothing to disagree with.
Err(e) => {
log::debug!("Consistency check: could not geolocate exit IP: {e}");
return Ok(ConsistencyResult::skip());
}
}
};
exit_timezone: Option<String>,
exit_country_code: Option<String>,
exit_ip: Option<String>,
) -> ConsistencyResult {
let (fp_tz, fp_lang) = fingerprint_locale(profile);
let mut mismatches = Vec::new();
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
if let (Some(exit), Some(fp)) = (&exit_timezone, &fp_tz) {
if !exit.eq_ignore_ascii_case(fp) {
mismatches.push("timezone".to_string());
}
}
if let (Some(cc), Some(lang)) = (&exit_cc, &fp_lang) {
if let (Some(cc), Some(lang)) = (&exit_country_code, &fp_lang) {
if language_matches_country(cc, lang) == Some(false) {
mismatches.push("language".to_string());
}
}
Ok(ConsistencyResult {
ConsistencyResult {
consistent: mismatches.is_empty(),
checked: true,
exit_ip,
exit_country_code: exit_cc,
exit_timezone: exit_tz,
exit_country_code,
exit_timezone,
fingerprint_timezone: fp_tz,
fingerprint_language: fp_lang,
mismatches,
})
}
}
#[tauri::command]
pub async fn check_profile_fingerprint_consistency(
profile_id: String,
/// Look up a still-valid cached exit for this profile.
fn cached_exit(key: &ExitCacheKey) -> Option<CachedExit> {
let now = crate::proxy_manager::now_secs();
exit_cache()
.get(&key.scope)
.filter(|c| {
c.identity == key.identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
})
.cloned()
}
/// Cache-only check. Never performs I/O, so it is safe to call before a launch
/// and for every profile in a bulk run. Returns an unchecked result on a miss.
pub fn check_profile_consistency_cached(profile: &BrowserProfile) -> ConsistencyResult {
let Some(key) = exit_cache_key(profile) else {
return ConsistencyResult::skip();
};
let Some(cached) = cached_exit(&key) else {
return ConsistencyResult::skip();
};
compare_exit_to_fingerprint(profile, cached.timezone, cached.country_code, cached.ip)
}
/// Drop any cached exit for this profile, so the next check re-measures.
pub fn invalidate_exit_cache(profile: &BrowserProfile) {
if let Some(key) = exit_cache_key(profile) {
exit_cache().remove(&key.scope);
}
}
/// Measure the exit through an already-normalized upstream and compare it to
/// the fingerprint.
///
/// `upstream` is what the launcher will actually hand the browser — a loopback
/// worker for VLESS and VPN, the resolved endpoint for a stored proxy — so one
/// code path covers every transport. `None` means a genuine direct connection,
/// which has nothing to disagree with.
pub async fn probe_and_check_consistency(
profile: &BrowserProfile,
upstream: Option<&crate::browser::ProxySettings>,
key: &ExitCacheKey,
) -> Result<ConsistencyResult, String> {
let profiles = crate::profile::ProfileManager::instance()
.list_profiles()
.map_err(|e| e.to_string())?;
let profile = profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
check_profile_consistency(&profile).await
if let Some(cached) = cached_exit(key) {
return Ok(compare_exit_to_fingerprint(
profile,
cached.timezone,
cached.country_code,
cached.ip,
));
}
let Some(settings) = upstream else {
return Ok(ConsistencyResult::skip());
};
let Some(url) = probe_url(settings) else {
return Ok(ConsistencyResult::skip());
};
// Resolve the exit IP through the proxy, then geolocate it with the SAME
// bundled MaxMind database the fingerprint generator (and the on-demand
// match) use. Using one geo source everywhere means the check can never
// disagree with what generation produced — a second source (e.g. ip-api)
// routinely reports a different IANA zone for the same IP in multi-zone
// countries, which would flag correctly-generated fingerprints and would
// leave the "match to proxy" fix unable to satisfy the check.
//
// Bounded independently of fetch_public_ip's own per-request timeout: that
// one races six endpoints and can add up to far longer than a user will wait
// in front of a launch.
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(Some(&url)))
.await
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
match crate::geolocation::get_geolocation(&exit_ip) {
Ok(geo) => {
let tz = Some(geo.timezone);
let cc = geo.locale.region.clone();
exit_cache().insert(
key.scope.clone(),
CachedExit {
fetched_at: crate::proxy_manager::now_secs(),
identity: key.identity.clone(),
timezone: tz.clone(),
country_code: cc.clone(),
ip: Some(exit_ip.clone()),
},
);
Ok(compare_exit_to_fingerprint(profile, tz, cc, Some(exit_ip)))
}
// Reached the exit but couldn't place it (database missing, or a private
// exit IP). Skip rather than warn on an unknown location — the same
// database gates fingerprint geo, so there's nothing to disagree with.
Err(e) => {
log::debug!("Consistency check: could not geolocate exit IP: {e}");
Ok(ConsistencyResult::skip())
}
}
}
/// Measure the exit this machine reaches without any proxy, and compare it to
/// the fingerprint.
///
/// Used when a profile declares a route that did not materialize: the browser
/// is about to connect directly, so the direct exit is the one that matters.
/// Deliberately NOT cached — a direct exit belongs to this machine's network,
/// not to any stored proxy, and it changes without a config edit.
pub async fn probe_direct_and_check(profile: &BrowserProfile) -> Result<ConsistencyResult, String> {
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(None))
.await
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
match crate::geolocation::get_geolocation(&exit_ip) {
Ok(geo) => Ok(compare_exit_to_fingerprint(
profile,
Some(geo.timezone),
geo.locale.region.clone(),
Some(exit_ip),
)),
Err(e) => {
log::debug!("Consistency check: could not geolocate direct exit IP: {e}");
Ok(ConsistencyResult::skip())
}
}
}
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
@@ -280,6 +383,10 @@ pub async fn match_profile_fingerprint_to_exit(
.to_string()
})?;
// The stored verdict was computed against the fingerprint we just rewrote.
// Leaving it would re-block the very launch this fix exists to unblock.
invalidate_exit_cache(&profile);
Ok(())
}
@@ -344,55 +451,218 @@ mod tests {
assert!(language_matches_country("CH", "de-CH").is_some());
}
#[test]
fn proxy_url_percent_encodes_credentials_and_skips_shadowsocks() {
let http = crate::browser::ProxySettings {
proxy_type: "http".into(),
host: "h".into(),
fn settings(
proxy_type: &str,
user: Option<&str>,
pass: Option<&str>,
) -> crate::browser::ProxySettings {
crate::browser::ProxySettings {
proxy_type: proxy_type.into(),
host: "gw.provider.io".into(),
port: 8080,
username: Some("u".into()),
password: Some("p".into()),
username: user.map(str::to_string),
password: pass.map(str::to_string),
vless_uri: None,
};
assert_eq!(proxy_url(&http, None).as_deref(), Some("http://u:p@h:8080"));
}
}
#[test]
fn probe_url_percent_encodes_credentials_and_skips_shadowsocks() {
assert_eq!(
probe_url(&settings("http", Some("u"), Some("p"))).as_deref(),
Some("http://u:p@gw.provider.io:8080")
);
// A password with URL-reserved characters must not break the authority —
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
let reserved = crate::browser::ProxySettings {
proxy_type: "http".into(),
host: "gw.provider.io".into(),
port: 8080,
username: Some("user".into()),
password: Some("ab/cd@ef".into()),
vless_uri: None,
};
assert_eq!(
proxy_url(&reserved, None).as_deref(),
probe_url(&settings("http", Some("user"), Some("ab/cd@ef"))).as_deref(),
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
);
// Username-only proxies keep their auth.
let user_only = crate::browser::ProxySettings {
proxy_type: "socks5".into(),
host: "h".into(),
port: 1080,
username: Some("justuser".into()),
password: None,
vless_uri: None,
};
assert_eq!(
proxy_url(&user_only, None).as_deref(),
Some("socks5://justuser@h:1080")
probe_url(&settings("socks4", Some("justuser"), None)).as_deref(),
Some("socks4://justuser@gw.provider.io:8080")
);
let ss = crate::browser::ProxySettings {
proxy_type: "ss".into(),
host: "h".into(),
port: 8080,
username: None,
password: None,
vless_uri: None,
// Shadowsocks cannot carry a reqwest probe, so it is skipped rather than
// guessed at.
assert_eq!(probe_url(&settings("ss", None, None)), None);
}
#[test]
fn probe_url_uses_socks5h_so_dns_resolves_at_the_exit() {
let url = probe_url(&settings("socks5", Some("u"), Some("p"))).unwrap();
assert!(
url.starts_with("socks5h://"),
"probe must not resolve the echo host locally, got {url}"
);
// The browser-facing builder is deliberately left alone.
assert!(
crate::proxy_manager::ProxyManager::build_proxy_url(&settings(
"socks5",
Some("u"),
Some("p")
))
.starts_with("socks5://")
);
}
fn profile_with_fingerprint(timezone: &str, language: &str) -> BrowserProfile {
let mut profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "p".into(),
browser: "wayfern".into(),
..Default::default()
};
assert_eq!(proxy_url(&ss, None), None);
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
fingerprint: Some(
serde_json::json!({ "timezone": timezone, "language": language }).to_string(),
),
..Default::default()
});
profile
}
#[test]
fn compare_flags_a_timezone_mismatch() {
let profile = profile_with_fingerprint("America/New_York", "en-US");
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
Some("1.2.3.4".into()),
);
assert!(result.checked);
assert!(!result.consistent);
assert!(result.mismatches.contains(&"timezone".to_string()));
}
#[test]
fn compare_accepts_a_matching_exit() {
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
Some("1.2.3.4".into()),
);
assert!(result.consistent, "{:?}", result.mismatches);
}
#[test]
fn compare_is_case_insensitive_on_timezone() {
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
let result = compare_exit_to_fingerprint(
&profile,
Some("europe/berlin".into()),
Some("DE".into()),
None,
);
assert!(result.consistent);
}
#[test]
fn compare_skips_dimensions_the_fingerprint_does_not_declare() {
// A profile with no fingerprint has nothing to contradict; it must not be
// reported as a mismatch and so must never block a launch.
let profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".into(),
..Default::default()
};
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
None,
);
assert!(result.consistent);
assert!(result.mismatches.is_empty());
}
#[test]
fn cached_check_reports_unchecked_without_a_proxy_or_vpn() {
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
let result = check_profile_consistency_cached(&profile);
assert!(!result.checked);
assert!(result.consistent, "an unchecked profile must never block");
}
#[test]
fn exit_cache_key_is_absent_without_a_proxy_or_vpn() {
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
assert_eq!(exit_cache_key(&profile), None);
}
#[test]
fn exit_cache_key_scopes_a_vpn_profile_by_vpn_id() {
let mut profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
profile.vpn_id = Some("vpn-abc".into());
let key = exit_cache_key(&profile).expect("vpn profiles must be cacheable");
assert_eq!(key.scope, "vpn:vpn-abc");
assert_eq!(key.identity, "vpn-abc");
}
#[test]
fn cached_entry_is_ignored_once_the_endpoint_identity_changes() {
let key = ExitCacheKey {
scope: "proxy:test-identity-change".into(),
identity: "http://old@host:1".into(),
};
exit_cache().insert(
key.scope.clone(),
CachedExit {
fetched_at: crate::proxy_manager::now_secs(),
identity: key.identity.clone(),
timezone: Some("Europe/Berlin".into()),
country_code: Some("DE".into()),
ip: Some("1.2.3.4".into()),
},
);
assert!(cached_exit(&key).is_some());
// Editing a stored proxy keeps its id but changes the endpoint; the old
// measurement must not be reused for the new one.
let rotated = ExitCacheKey {
identity: "http://new@host:2".into(),
..key.clone()
};
assert!(cached_exit(&rotated).is_none());
exit_cache().remove(&key.scope);
}
#[test]
fn cached_entry_expires_after_the_ttl() {
let key: ExitCacheKey = ExitCacheKey {
scope: "proxy:test-ttl".into(),
identity: "http://host:1".into(),
};
exit_cache().insert(
key.scope.clone(),
CachedExit {
fetched_at: crate::proxy_manager::now_secs() - EXIT_CACHE_TTL_SECS - 1,
identity: key.identity.clone(),
timezone: Some("Europe/Berlin".into()),
country_code: Some("DE".into()),
ip: None,
},
);
assert!(cached_exit(&key).is_none());
exit_cache().remove(&key.scope);
}
#[test]
fn exit_cache_survives_a_poisoned_lock() {
// A panic under this lock must degrade the check, not brick every
// subsequent launch that consults it.
let _ = std::thread::spawn(|| {
let _guard = EXIT_CACHE.lock().unwrap();
panic!("poison the cache");
})
.join();
assert!(EXIT_CACHE.is_poisoned());
exit_cache().remove("nonexistent-scope");
}
}
+538
View File
@@ -0,0 +1,538 @@
//! The pre-spawn launch gate.
//!
//! Two findings can stop a launch being what the user expects:
//!
//! * a **VPN/proxy extension** in the profile, which can override the proxy
//! Donut configured and silently move the browser's exit away from the one
//! the fingerprint was generated for — a warning, since Donut cannot tell
//! from outside whether it is actually routing anything;
//! * a measured **exit/fingerprint mismatch**, which is a hard block: the
//! browser does not start until the user explicitly proceeds.
//!
//! The enforcing half runs inside `browser_runner::launch_browser_internal`,
//! after the upstream has been normalized (so VLESS and VPN profiles are
//! reachable at all) and before the local proxy starts or the browser spawns.
//! `get_profile_pre_launch_checks` is the cheap, local-only half the UI calls
//! first, so a profile whose exit is already known blocks without starting a
//! single worker.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use crate::fingerprint_consistency::{self, ConsistencyResult};
use crate::profile::types::BrowserProfile;
use crate::vpn_extension_detect::{self, DetectedVpnExtension};
/// How long a "launch anyway" decision stays redeemable. Long enough to read
/// the dialog, short enough that a token cannot sit around across a session.
const CONSENT_TTL_SECS: u64 = 10 * 60;
/// What the gate is allowed to do on this launch.
#[derive(Debug, Clone, Default)]
pub enum FingerprintGate {
/// Block on a measured mismatch. The default, and what the GUI uses.
#[default]
Enforce,
/// Measure only from cache and report; never block, never probe the network.
/// Automation runs here: a headless client has no dialog to answer and
/// cannot regenerate its fingerprint mid-run, so a hard failure would turn a
/// warning into an outage for a whole fleet.
Advisory,
/// The user already said "launch anyway" and handed back a token.
Consented(String),
}
struct PendingConsent {
profile_id: String,
fingerprint_hash: String,
exit_identity: String,
issued_at: u64,
}
lazy_static::lazy_static! {
static ref CONSENTS: Mutex<HashMap<String, PendingConsent>> = Mutex::new(HashMap::new());
}
fn consents() -> std::sync::MutexGuard<'static, HashMap<String, PendingConsent>> {
CONSENTS.lock().unwrap_or_else(|e| e.into_inner())
}
fn random_token() -> String {
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 16];
rng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Issue a single-use token authorizing one launch of this exact
/// (profile, fingerprint, exit) combination.
///
/// A plain `bypass: bool` cannot express this: a "proceed" the user granted
/// while looking at proxy A would silently authorize a launch through proxy B
/// if they changed it before the retry landed.
pub fn mint_consent(profile: &BrowserProfile, exit_identity: &str) -> String {
let token = random_token();
let now = crate::proxy_manager::now_secs();
let mut store = consents();
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
store.insert(
token.clone(),
PendingConsent {
profile_id: profile.id.to_string(),
fingerprint_hash: crate::launch_gate_prefs::fingerprint_hash(profile),
exit_identity: exit_identity.to_string(),
issued_at: now,
},
);
token
}
/// Redeem a consent token. Single use — a redeemed token is removed whether or
/// not it validated, so a leaked token cannot be replayed.
pub fn redeem_consent(
token: &str,
profile: &BrowserProfile,
exit_identity: &str,
) -> Result<(), String> {
let now = crate::proxy_manager::now_secs();
let pending = {
let mut store = consents();
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
store.remove(token)
};
let Some(pending) = pending else {
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
};
if pending.profile_id != profile.id.to_string()
|| pending.fingerprint_hash != crate::launch_gate_prefs::fingerprint_hash(profile)
|| pending.exit_identity != exit_identity
{
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
}
Ok(())
}
fn mismatch_error(result: &ConsistencyResult, token: &str) -> String {
serde_json::json!({
"code": "FINGERPRINT_EXIT_MISMATCH",
"params": {
"token": token,
"exitIp": result.exit_ip.clone().unwrap_or_default(),
"exitCountry": result.exit_country_code.clone().unwrap_or_default(),
"exitTimezone": result.exit_timezone.clone().unwrap_or_default(),
"fingerprintTimezone": result.fingerprint_timezone.clone().unwrap_or_default(),
"fingerprintLanguage": result.fingerprint_language.clone().unwrap_or_default(),
"mismatches": result.mismatches.join(","),
}
})
.to_string()
}
fn gate_disabled() -> bool {
crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|s| s.fingerprint_gate_disabled)
.unwrap_or(false)
}
fn extension_warning_disabled() -> bool {
crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|s| s.vpn_extension_warning_disabled)
.unwrap_or(false)
}
/// Identity used for consent and acknowledgement when the browser will connect
/// directly. Distinct from any proxy identity, so accepting a direct-exit
/// mismatch never disarms the gate for a proxied one.
const DIRECT_EXIT_IDENTITY: &str = "direct";
/// Gate a launch that will connect directly despite the profile declaring a
/// route. Measures the exit the browser will really use.
async fn enforce_direct_exit(
profile: &BrowserProfile,
gate: &FingerprintGate,
) -> Result<(), String> {
if gate_disabled() {
return Ok(());
}
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, DIRECT_EXIT_IDENTITY) {
return Ok(());
}
if let FingerprintGate::Consented(token) = gate {
return redeem_consent(token, profile, DIRECT_EXIT_IDENTITY);
}
// Automation never probes; without a cache to consult there is nothing to say.
if matches!(gate, FingerprintGate::Advisory) {
return Ok(());
}
let result = match fingerprint_consistency::probe_direct_and_check(profile).await {
Ok(result) => result,
Err(e) => {
log::warn!(
"Fingerprint gate: direct exit probe failed for profile {}, allowing launch: {e}",
profile.name
);
return Ok(());
}
};
if !result.checked || result.consistent {
return Ok(());
}
let token = mint_consent(profile, DIRECT_EXIT_IDENTITY);
Err(mismatch_error(&result, &token))
}
/// The enforcing gate. Called from the launch pipeline once the upstream is
/// normalized and before anything expensive or user-visible happens.
///
/// Fails **open** on every degradation — probe failure, timeout, missing geo
/// database, private exit IP. The gate blocks only on a positively measured
/// mismatch; a flaky IP-echo endpoint must never make profiles unlaunchable.
pub async fn enforce_fingerprint_gate(
profile: &BrowserProfile,
upstream: Option<&crate::browser::ProxySettings>,
gate: &FingerprintGate,
) -> Result<(), String> {
// A profile that declares no route is genuinely direct: the browser's exit is
// this machine, which is what an un-proxied fingerprint should describe.
//
// But a profile that DOES declare one and still arrives here with no upstream
// is about to go direct anyway — a deleted or unresolvable proxy resolves to
// `None` and the launch continues. That is the exact leak this gate exists to
// stop, so it must be measured, not waved through.
let declares_route = profile.proxy_id.is_some() || profile.vpn_id.is_some();
if upstream.is_none() && !declares_route {
return Ok(());
}
if gate_disabled() {
return Ok(());
}
// Decide *once*, before any consent handling, whether this launch is going
// out directly. Both a route that no longer resolves (deleted proxy) and one
// that produced no usable upstream (a VPN worker with no local port) end up
// connecting directly, and both must mint and redeem consent under the same
// identity — splitting that decision across the function meant the first
// attempt minted under "direct" while the retry redeemed against the proxy
// identity, so "Launch anyway" could never succeed.
let key = fingerprint_consistency::exit_cache_key(profile);
if key.is_none() || upstream.is_none() {
log::warn!(
"Fingerprint gate: {} declares a proxy/VPN that yielded no usable upstream; \
measuring the direct exit it will actually use",
profile.name
);
return enforce_direct_exit(profile, gate).await;
}
let key = key.expect("checked above");
// Ack first: a persisted acknowledgement already permits this launch, so a
// stale token must not turn it into a hard failure.
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, &key.identity) {
return Ok(());
}
if let FingerprintGate::Consented(token) = gate {
redeem_consent(token, profile, &key.identity)?;
return Ok(());
}
let result = if matches!(gate, FingerprintGate::Advisory) {
// Automation: answer from a warm cache or say nothing. Probing here would
// add seconds to every profile in a batch run.
fingerprint_consistency::check_profile_consistency_cached(profile)
} else {
match fingerprint_consistency::probe_and_check_consistency(profile, upstream, &key).await {
Ok(result) => result,
Err(e) => {
log::warn!(
"Fingerprint gate: exit probe failed for profile {}, allowing launch: {e}",
profile.name
);
return Ok(());
}
}
};
if !result.checked || result.consistent {
return Ok(());
}
// Automation is the only caller allowed past a measured mismatch, because it
// has no dialog to answer. A proxy-capable extension in the profile does NOT
// earn the same pass: it makes the measurement less trustworthy, and a route
// that might be worse than measured is a reason for more scrutiny, not less.
// Waiving the block on it also meant any download manager holding Chromium's
// `proxy` permission silently disarmed the gate for good.
if matches!(gate, FingerprintGate::Advisory) {
log::warn!(
"Fingerprint gate: {} launching with a known exit mismatch ({})",
profile.name,
result.mismatches.join(", ")
);
if let Err(e) = crate::events::emit("fingerprint-consistency-warning", &result) {
log::warn!("Failed to emit fingerprint consistency warning: {e}");
}
return Ok(());
}
let token = mint_consent(profile, &key.identity);
Err(mismatch_error(&result, &token))
}
/// Everything the UI needs to decide whether to stop a launch, answered
/// without touching the network or starting any worker.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreLaunchChecks {
pub vpn_extensions: Vec<DetectedVpnExtension>,
pub scan_state: String,
/// Cache-only; `checked` is false when the exit has not been measured yet.
pub consistency: ConsistencyResult,
/// True when the enforcing gate will still probe during the launch, so the
/// UI can say the check is not finished rather than implying it passed.
pub exit_probe_pending: bool,
/// An extension holding the `proxy` permission is present, so any exit
/// measurement describes a route the browser may not take. Informational
/// only — it never relaxes the block.
pub exit_measurement_unreliable: bool,
/// Present only when a cached mismatch is already blocking, so "launch
/// anyway" can proceed without a second round trip.
pub consent_token: Option<String>,
}
fn load_profile(profile_id: &str) -> Result<BrowserProfile, String> {
crate::profile::ProfileManager::instance()
.list_profiles()
.map_err(|e| e.to_string())?
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))
}
#[tauri::command]
pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaunchChecks, String> {
let profile = load_profile(&profile_id)?;
// The setting suppresses the extension report entirely, which is safe
// precisely because nothing enforcing depends on it: the scan feeds the
// dialog's warning and the "measurement may be unreliable" note, never the
// decision to block.
let scan = if extension_warning_disabled() {
vpn_extension_detect::ExtensionScan {
extensions: Vec::new(),
scan_state: "scanned".to_string(),
}
} else {
vpn_extension_detect::scan_profile(&profile)
};
// Drop anything the user has already acknowledged for this profile, so the
// dialog only ever opens for something new.
let vpn_extensions: Vec<DetectedVpnExtension> = scan
.extensions
.iter()
.filter(|e| {
!crate::launch_gate_prefs::extensions_acked(&profile_id, std::slice::from_ref(&e.key))
})
.cloned()
.collect();
let exit_measurement_unreliable = vpn_extension_detect::has_proxy_control(&scan);
let disabled = gate_disabled();
let key = fingerprint_consistency::exit_cache_key(&profile);
let consistency = if disabled {
ConsistencyResult::skip()
} else {
fingerprint_consistency::check_profile_consistency_cached(&profile)
};
let already_acked = key
.as_ref()
.is_some_and(|k| crate::launch_gate_prefs::fingerprint_ack_matches(&profile, &k.identity));
let blocking = consistency.checked && !consistency.consistent && !already_acked;
let consent_token = match (&key, blocking) {
(Some(k), true) => Some(mint_consent(&profile, &k.identity)),
_ => None,
};
Ok(PreLaunchChecks {
vpn_extensions,
scan_state: scan.scan_state,
consistency: if blocking {
consistency
} else {
ConsistencyResult::skip()
},
exit_probe_pending: !disabled && !already_acked && key.is_some() && !blocking,
exit_measurement_unreliable,
consent_token,
})
}
/// Persist "don't ask me again" choices from the gate dialog.
#[tauri::command]
pub async fn ack_launch_gate(
profile_id: String,
ack_fingerprint: bool,
ack_extension_keys: Vec<String>,
) -> Result<(), String> {
let profile = load_profile(&profile_id)?;
if ack_fingerprint {
// Must match the identity the block was issued against. A profile whose
// route did not resolve is gated on the direct exit and has no cache key,
// so falling back here is what makes "don't block again" stick for it.
let identity = fingerprint_consistency::exit_cache_key(&profile)
.map(|key| key.identity)
.unwrap_or_else(|| DIRECT_EXIT_IDENTITY.to_string());
crate::launch_gate_prefs::ack_fingerprint(&profile, &identity);
}
crate::launch_gate_prefs::ack_extensions(&profile_id, &ack_extension_keys);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn profile_with(fingerprint: &str) -> BrowserProfile {
let mut profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".into(),
..Default::default()
};
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
fingerprint: Some(fingerprint.to_string()),
..Default::default()
});
profile
}
#[test]
fn consent_token_authorizes_exactly_one_launch() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
assert!(redeem_consent(&token, &profile, "http://gw:1").is_ok());
// Replaying it must fail, so a leaked token cannot re-authorize.
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
}
#[test]
fn consent_token_is_rejected_for_a_different_profile() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let other = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
assert!(redeem_consent(&token, &other, "http://gw:1").is_err());
}
#[test]
fn consent_token_is_rejected_after_the_fingerprint_changes() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
let mut regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
regenerated.id = profile.id;
assert!(redeem_consent(&token, &regenerated, "http://gw:1").is_err());
}
#[test]
fn consent_token_is_rejected_after_the_exit_changes() {
// The reason a bare `bypass: bool` is not enough: consent granted for one
// proxy must not authorize a launch through another.
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
assert!(redeem_consent(&token, &profile, "http://other:2").is_err());
}
#[test]
fn an_unknown_token_is_rejected() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let err = redeem_consent("deadbeef", &profile, "http://gw:1").unwrap_err();
assert!(err.contains("LAUNCH_CONSENT_EXPIRED"), "{err}");
}
#[test]
fn expired_tokens_are_swept_and_rejected() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
// Back-date it past the TTL.
{
let mut store = consents();
if let Some(pending) = store.get_mut(&token) {
pending.issued_at = crate::proxy_manager::now_secs() - CONSENT_TTL_SECS - 1;
}
}
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
}
#[test]
fn mismatch_error_carries_the_details_the_dialog_renders() {
let result = ConsistencyResult {
consistent: false,
checked: true,
exit_ip: Some("1.2.3.4".into()),
exit_country_code: Some("DE".into()),
exit_timezone: Some("Europe/Berlin".into()),
fingerprint_timezone: Some("America/New_York".into()),
fingerprint_language: Some("en-US".into()),
mismatches: vec!["timezone".into(), "language".into()],
};
let encoded = mismatch_error(&result, "tok");
let parsed: serde_json::Value = serde_json::from_str(&encoded).unwrap();
assert_eq!(parsed["code"], "FINGERPRINT_EXIT_MISMATCH");
assert_eq!(parsed["params"]["token"], "tok");
assert_eq!(parsed["params"]["exitTimezone"], "Europe/Berlin");
assert_eq!(parsed["params"]["fingerprintTimezone"], "America/New_York");
// params values must be strings for the frontend's interpolation.
assert_eq!(parsed["params"]["mismatches"], "timezone,language");
}
#[tokio::test]
async fn gate_allows_a_direct_connection_without_measuring() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
assert!(
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
.await
.is_ok()
);
}
#[tokio::test]
async fn gate_allows_a_profile_with_no_proxy_or_vpn() {
// A profile that declares no route has no upstream either — that pairing is
// the only one the launcher can actually produce. It must return without
// measuring anything, so this stays a pure unit test with no network.
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
assert!(
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
.await
.is_ok()
);
}
#[tokio::test]
async fn consent_for_a_direct_launch_is_redeemable_by_the_gate() {
// Regression: a route that yields no usable upstream is gated on the direct
// exit, so consent is minted under DIRECT_EXIT_IDENTITY. If the gate then
// redeemed against the proxy/VPN identity instead, "Launch anyway" would
// fail forever and the profile could never be started.
let mut profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
profile.vpn_id = Some("vpn-with-no-port".into());
let token = mint_consent(&profile, DIRECT_EXIT_IDENTITY);
// No upstream: the launcher could not bring the route up.
let result = enforce_fingerprint_gate(&profile, None, &FingerprintGate::Consented(token)).await;
assert!(
result.is_ok(),
"consent minted for the direct exit must be redeemable, got {result:?}"
);
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Persisted "I know, launch it anyway" acknowledgements for the launch gate.
//!
//! Deliberately NOT synced. An acknowledgement is a statement about this
//! machine's operator ("I understand this profile's exit disagrees with its
//! fingerprint"), not a property of the profile. Syncing it would let one
//! teammate disarm another's gate, and writing it into profile metadata would
//! bump `updated_at` and make a local dismissal look like a remote edit.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use crate::profile::types::BrowserProfile;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FingerprintAck {
/// Hash of the fingerprint that was acknowledged.
pub fingerprint_hash: String,
/// Exit endpoint identity it was acknowledged against.
pub exit_identity: String,
pub acked_at: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LaunchGatePrefs {
#[serde(default)]
pub fingerprint_acks: HashMap<String, FingerprintAck>,
/// Profile id -> acknowledged extension keys.
#[serde(default)]
pub vpn_extension_acks: HashMap<String, Vec<String>>,
}
lazy_static::lazy_static! {
/// Serializes read-modify-write so two concurrent acknowledgements in a bulk
/// run cannot clobber each other.
static ref PREFS_LOCK: Mutex<()> = Mutex::new(());
}
fn prefs_file() -> PathBuf {
crate::app_dirs::data_subdir().join("launch_gate_prefs.json")
}
pub fn load() -> LaunchGatePrefs {
let Ok(content) = std::fs::read_to_string(prefs_file()) else {
return LaunchGatePrefs::default();
};
serde_json::from_str(&content).unwrap_or_else(|e| {
log::warn!("Failed to parse launch gate prefs, ignoring them: {e}");
LaunchGatePrefs::default()
})
}
fn save(prefs: &LaunchGatePrefs) {
let path = prefs_file();
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create launch gate prefs dir: {e}");
return;
}
}
match serde_json::to_string_pretty(prefs) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
log::warn!("Failed to write launch gate prefs: {e}");
}
}
Err(e) => log::warn!("Failed to serialize launch gate prefs: {e}"),
}
}
fn update(mutate: impl FnOnce(&mut LaunchGatePrefs)) {
let _guard = PREFS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut prefs = load();
mutate(&mut prefs);
save(&prefs);
}
/// Stable digest of a profile's stored fingerprint, so an acknowledgement stops
/// applying the moment the fingerprint is regenerated or matched to a new exit.
pub fn fingerprint_hash(profile: &BrowserProfile) -> String {
use sha2::{Digest, Sha256};
let fingerprint = profile
.wayfern_config
.as_ref()
.and_then(|c| c.fingerprint.as_deref())
.unwrap_or("");
let mut hasher = Sha256::new();
hasher.update(fingerprint.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
/// Record that the user accepted this exact (fingerprint, exit) mismatch.
pub fn ack_fingerprint(profile: &BrowserProfile, exit_identity: &str) {
let ack = FingerprintAck {
fingerprint_hash: fingerprint_hash(profile),
exit_identity: exit_identity.to_string(),
acked_at: crate::proxy_manager::now_secs(),
};
let profile_id = profile.id.to_string();
update(|prefs| {
prefs.fingerprint_acks.insert(profile_id, ack);
});
}
/// Whether the user already accepted the mismatch this profile currently has.
///
/// Bound to both the fingerprint and the exit endpoint on purpose: the old
/// per-profile "don't warn again" flag never expired, so one dismissal left a
/// profile unprotected forever, including after its proxy was swapped for one
/// in a different country.
pub fn fingerprint_ack_matches(profile: &BrowserProfile, exit_identity: &str) -> bool {
let prefs = load();
prefs
.fingerprint_acks
.get(&profile.id.to_string())
.is_some_and(|ack| {
ack.fingerprint_hash == fingerprint_hash(profile) && ack.exit_identity == exit_identity
})
}
pub fn ack_extensions(profile_id: &str, keys: &[String]) {
if keys.is_empty() {
return;
}
let profile_id = profile_id.to_string();
let keys = keys.to_vec();
update(|prefs| {
let entry = prefs.vpn_extension_acks.entry(profile_id).or_default();
for key in keys {
if !entry.contains(&key) {
entry.push(key);
}
}
});
}
/// True when every one of these extensions has already been acknowledged for
/// this profile. Installing a *different* VPN extension later re-warns, because
/// its key is not in the acknowledged set.
pub fn extensions_acked(profile_id: &str, keys: &[String]) -> bool {
if keys.is_empty() {
return true;
}
let prefs = load();
let Some(acked) = prefs.vpn_extension_acks.get(profile_id) else {
return false;
};
keys.iter().all(|k| acked.contains(k))
}
/// Drop everything remembered for a profile, for use when it is deleted.
pub fn forget_profile(profile_id: &str) {
let profile_id = profile_id.to_string();
update(|prefs| {
prefs.fingerprint_acks.remove(&profile_id);
prefs.vpn_extension_acks.remove(&profile_id);
});
}
#[cfg(test)]
mod tests {
use super::*;
fn profile_with(fingerprint: &str) -> BrowserProfile {
let mut profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".into(),
..Default::default()
};
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
fingerprint: Some(fingerprint.to_string()),
..Default::default()
});
profile
}
#[test]
fn fingerprint_hash_changes_with_the_fingerprint() {
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let b = profile_with(r#"{"timezone":"America/New_York"}"#);
assert_ne!(fingerprint_hash(&a), fingerprint_hash(&b));
}
#[test]
fn fingerprint_hash_is_stable_for_the_same_fingerprint() {
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let b = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
assert_eq!(fingerprint_hash(&a), fingerprint_hash(&b));
}
#[test]
fn a_profile_without_a_fingerprint_still_hashes() {
let profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
..Default::default()
};
assert!(!fingerprint_hash(&profile).is_empty());
}
#[test]
fn acks_round_trip_and_rearm_on_change() {
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
ack_fingerprint(&profile, "http://gw:1");
assert!(fingerprint_ack_matches(&profile, "http://gw:1"));
// Swapping the proxy re-arms the gate: the mismatch the user accepted is
// not the mismatch they now have.
assert!(!fingerprint_ack_matches(&profile, "http://other:2"));
// Regenerating the fingerprint re-arms it too.
let regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
let mut same_id = regenerated.clone();
same_id.id = profile.id;
assert!(!fingerprint_ack_matches(&same_id, "http://gw:1"));
}
#[test]
fn extension_acks_are_per_key() {
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
let profile_id = uuid::Uuid::new_v4().to_string();
let nord = vec!["crx:aaaa".to_string()];
let other = vec!["crx:bbbb".to_string()];
assert!(!extensions_acked(&profile_id, &nord));
ack_extensions(&profile_id, &nord);
assert!(extensions_acked(&profile_id, &nord));
// A different extension installed later must warn again.
assert!(!extensions_acked(&profile_id, &other));
assert!(!extensions_acked(
&profile_id,
&[nord[0].clone(), other[0].clone()]
));
// Nothing to acknowledge is trivially acknowledged, so an empty scan never
// opens the dialog.
assert!(extensions_acked(&profile_id, &[]));
}
#[test]
fn forgetting_a_profile_clears_both_kinds_of_ack() {
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let profile_id = profile.id.to_string();
ack_fingerprint(&profile, "http://gw:1");
ack_extensions(&profile_id, &["crx:aaaa".to_string()]);
forget_profile(&profile_id);
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
assert!(!extensions_acked(&profile_id, &["crx:aaaa".to_string()]));
}
#[test]
fn corrupt_prefs_file_is_ignored_rather_than_fatal() {
let dir = tempfile::tempdir().expect("tempdir").keep();
let _guard = crate::app_dirs::set_test_data_dir(dir.clone());
std::fs::create_dir_all(dir.join("data")).unwrap();
std::fs::write(
dir.join("data").join("launch_gate_prefs.json"),
"{ not json",
)
.unwrap();
// Must not panic, and must fail closed (nothing acknowledged).
let prefs = load();
assert!(prefs.fingerprint_acks.is_empty());
}
}
+128 -6
View File
@@ -23,6 +23,16 @@ pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Displ
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
}
/// A VLESS URI Donut cannot use, carrying which part is unsupported so the UI
/// can say so instead of implying a typo.
pub(crate) fn vless_config_error(error: &crate::xray::XrayError) -> String {
serde_json::json!({
"code": "VLESS_CONFIG_INVALID",
"params": { "reason": error.reason_code(), "detail": error.to_string() }
})
.to_string()
}
fn e2e_automation_enabled() -> bool {
#[cfg(feature = "e2e")]
{
@@ -51,6 +61,7 @@ mod automation_rate_limiter;
mod browser;
mod browser_runner;
mod browser_version_manager;
mod cdp_target;
mod default_browser;
pub mod dns_blocklist;
mod downloaded_browsers_registry;
@@ -64,14 +75,19 @@ mod geolocation;
mod group_manager;
mod human_typing;
mod ip_utils;
mod launch_gate;
mod launch_gate_prefs;
mod log_redaction;
mod platform_browser;
mod profile;
mod profile_import;
mod profile_importer;
mod proxy_manager;
pub mod proxy_runner;
pub mod proxy_server;
pub mod proxy_storage;
mod remote_exit;
mod remote_handoff;
mod remote_session;
mod settings_manager;
pub mod socks5_local;
@@ -80,6 +96,7 @@ mod synchronizer;
pub mod traffic_stats;
mod wayfern_manager;
mod wayfern_terms;
mod window_decorations;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
pub mod cloud_auth;
mod cloud_errors;
@@ -93,6 +110,7 @@ mod tag_manager;
mod team_lock;
mod version_updater;
pub mod vpn;
mod vpn_extension_detect;
pub mod vpn_worker_runner;
pub mod vpn_worker_storage;
pub mod xray;
@@ -312,6 +330,16 @@ async fn create_stored_proxy(
}
}
/// Validate a VLESS URI without touching the network, so the proxy form can
/// tell the user their setup is unsupported while they are still editing it
/// rather than only after they try to save or launch.
#[tauri::command]
fn validate_vless_uri(uri: String) -> Result<(), String> {
crate::xray::parse_vless_uri(uri.trim())
.map(|_| ())
.map_err(|error| vless_config_error(&error))
}
#[tauri::command]
async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>, String> {
Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies())
@@ -1333,11 +1361,27 @@ async fn get_remote_session(
/// so a handful of short launches bills an allowance meant for a hundred.
#[tauri::command]
async fn stop_remote_session(
app_handle: tauri::AppHandle,
session_id: String,
) -> Result<remote_session::EndRemoteSessionOutcome, String> {
remote_session::end_remote_session(&session_id)
let outcome = remote_session::end_remote_session(&session_id)
.await
.map_err(|e| remote_session_error("stop", e))
.map_err(|e| remote_session_error("stop", e))?;
// The stream normally reports the close, but a stop must not depend on a
// socket being up: without this the session's work would sit in cloud storage
// with nothing to pull it, and the profile would look ready to open locally
// while its local copy still predated the session.
remote_session::note_session_stopped(&app_handle, &session_id);
Ok(outcome)
}
/// Which profiles cannot be launched locally right now, and why.
///
/// Backed by the same store the launch gate reads, so the button the UI disables
/// and the refusal the backend would produce can never disagree.
#[tauri::command]
fn get_remote_handoff_states() -> std::collections::HashMap<String, remote_handoff::HandoffState> {
remote_handoff::states()
}
/// Subscribe to session transitions. Idempotent.
@@ -1426,7 +1470,7 @@ async fn save_cookie_bot_schedule(
// Refused here rather than at 02:00: a profile that can never be warmed
// should never reach a schedule row, an hour of quota or a leased host.
let profile = cookie_bot_profile(&profile_id)?;
cookie_bot::bot_precondition(&profile)?;
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
// The frontend sends the user's choices; the profile facts the server refuses
// a run on are stamped here, from the profile itself, so a caller cannot
// assert them.
@@ -1486,7 +1530,8 @@ async fn run_cookie_bot_now(
profile_id: String,
max_minutes: Option<u32>,
) -> Result<cookie_bot::CookieBotRunStarted, String> {
cookie_bot::bot_precondition(&cookie_bot_profile(&profile_id)?)?;
let profile = cookie_bot_profile(&profile_id)?;
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
cookie_bot::run_now(&profile_id, max_minutes)
.await
.map_err(|e| cookie_bot_error("run start", e))
@@ -1748,7 +1793,12 @@ pub fn run_with_builder(
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::VISIBLE
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
& !tauri_plugin_window_state::StateFlags::FULLSCREEN
// Whether the window is decorated is decided per-session by
// `window_decorations::use_client_side_decorations()`, not by what
// a previous run saved. Restoring it would put a real titlebar back
// on top of the one the app draws — or strip both.
& !tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.build(),
);
@@ -1788,9 +1838,21 @@ pub fn run_with_builder(
None => win_builder,
};
// The app draws its own titlebar. macOS keeps the native one and makes
// it transparent (below); Windows and Linux drop decorations entirely and
// render their own controls.
#[cfg(target_os = "windows")]
let win_builder = win_builder.decorations(false);
// Linux opts out on the one configuration where dropping decorations can
// make things worse rather than better — see `use_client_side_decorations`.
#[cfg(target_os = "linux")]
let win_builder = if window_decorations::use_client_side_decorations() {
win_builder.decorations(false)
} else {
win_builder
};
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
@@ -1823,6 +1885,44 @@ pub fn run_with_builder(
});
}
// Publish the desktop's titlebar button layout to the frontend. Runs
// here because `setup` is the GTK main thread, which `gtk::Settings`
// requires.
//
// The decorated state is logged alongside it: "my window has no titlebar"
// and "my window has two titlebars" are both reports that hinge on this
// one boolean, and it is otherwise invisible after the fact.
#[cfg(target_os = "linux")]
{
log::info!(
"Linux window decorations: server-side = {:?}",
window.is_decorated()
);
// tao makes the window visible before it clears the decorations, so it
// is realized while still framed and the frame extents come out of the
// size we asked for (a requested 880x500 arrives noticeably smaller).
//
// Only correct that on a first run. Once window-state has geometry
// saved, that geometry is the user's and has already been restored —
// re-applying the default here would move and resize their window on
// every launch, and the plugin would then persist the reset.
let has_saved_geometry = app
.path()
.app_config_dir()
.map(|dir| dir.join(".window-state.json").exists())
.unwrap_or(false);
if window_decorations::use_client_side_decorations() && !has_saved_geometry {
if let Err(e) = window.set_size(tauri::LogicalSize::new(880.0, 500.0)) {
log::warn!("Failed to re-apply the window size after dropping decorations: {e}");
}
if let Err(e) = window.center() {
log::warn!("Failed to re-center the window after dropping decorations: {e}");
}
}
}
window_decorations::init(app.handle());
// Set transparent titlebar for macOS
#[cfg(target_os = "macos")]
{
@@ -2535,6 +2635,12 @@ pub fn run_with_builder(
// and would only be refused on a loop; the frontend starts it again
// through `start_remote_session_events` once the user signs in.
remote_session::start_session_events(app_handle_cloud.clone());
// A session that finished while this machine was shut, or whose pull
// ran out of retries offline, leaves a profile blocked from launching
// with its work still in cloud storage. Signing in is the first moment
// that pull can succeed, so it is where it is retried.
remote_handoff::resume_pending_pulls(&app_handle_cloud);
}
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
});
@@ -2649,8 +2755,11 @@ pub fn run_with_builder(
clear_all_traffic_stats,
clear_profile_traffic_stats,
get_traffic_stats_for_period,
fingerprint_consistency::check_profile_fingerprint_consistency,
fingerprint_consistency::match_profile_fingerprint_to_exit,
launch_gate::get_profile_pre_launch_checks,
launch_gate::ack_launch_gate,
window_decorations::get_window_decoration_layout,
validate_vless_uri,
get_sync_settings,
save_sync_settings,
set_profile_sync_mode,
@@ -2731,6 +2840,7 @@ pub fn run_with_builder(
list_remote_sessions,
get_remote_session,
stop_remote_session,
get_remote_handoff_states,
start_remote_session_events,
stop_remote_session_events,
get_remote_session_events_status,
@@ -2746,6 +2856,14 @@ pub fn run_with_builder(
get_cookie_bot_presets,
get_remote_hours_quota,
get_cookie_bot_usage,
// Defined in `cookie_bot.rs` rather than here because they carry no local
// precondition — there is no profile to look up and no `bot_precondition`
// to apply. Unregistered they are unreachable, and the saved-list tab
// fails at runtime with "command not found" rather than at build time.
cookie_bot::get_cookie_bot_user_templates,
cookie_bot::create_cookie_bot_user_template,
cookie_bot::update_cookie_bot_user_template,
cookie_bot::delete_cookie_bot_user_template,
// Profile password commands
set_profile_password,
change_profile_password,
@@ -2809,6 +2927,10 @@ mod tests {
crate::remote_session::EVENT_SESSION_STATE,
crate::remote_session::EVENT_SESSION_SNAPSHOT,
crate::remote_session::EVENT_STREAM_STATUS,
// The launch gate is emitted from the same place for the same reason: a
// Run button that does not hear about it stays enabled over a profile the
// backend will refuse, or over unsynced work it must not open.
crate::remote_handoff::EVENT_REMOTE_HANDOFF,
] {
assert!(
client.contains(&format!("\"{event}\"")),
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -41,7 +41,7 @@ fn is_kept(name: &str) -> bool {
/// step since it regenerates — leaves a populated `Default/` without it. Such a
/// directory would then be treated as stale and removed wholesale, destroying the
/// Extensions and Bookmarks this feature exists to preserve.
fn is_profile_dir_name(name: &str) -> bool {
pub(crate) fn is_profile_dir_name(name: &str) -> bool {
matches!(name, "Default" | "Guest Profile" | "System Profile")
|| name
.strip_prefix("Profile ")
+4
View File
@@ -479,6 +479,10 @@ impl ProfileManager {
);
}
// Launch-gate acknowledgements are keyed by profile id and are not synced,
// so nothing else would ever clean them up.
crate::launch_gate_prefs::forget_profile(profile_id);
// Remember sync mode before deleting local files
let was_sync_enabled = profile.is_sync_enabled();
+551
View File
@@ -0,0 +1,551 @@
//! Copying a source profile into the new one.
//!
//! Two things a plain recursive copy gets wrong, both of which produce a
//! profile that looks imported and is not:
//!
//! - **Torn databases.** Users import from a browser they are still using. A
//! naive walk copies `Cookies` and `Cookies-wal` at different instants, and
//! Chromium's `sql::Database` razes the result on open. `VACUUM INTO` takes a
//! transactionally consistent snapshot instead, WAL content included, even
//! while the source holds the file.
//! - **Multi-GB of caches.** `Cache/`, `Code Cache/`, `GPUCache/` and friends
//! carry no user state and dominate both copy time and disk use.
use std::fs;
use std::path::Path;
/// Directories that never carry user state. Matched on the path relative to the
/// profile root, so `Service Worker/CacheStorage` is dropped while
/// `Service Worker/Database` survives.
const SKIP_DIRS: &[&str] = &[
"Cache",
"Code Cache",
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnCache",
"DawnGraphiteCache",
"DawnWebGPUCache",
"GraphiteDawnCache",
"GPUPersistentCache",
"Service Worker/CacheStorage",
"Service Worker/ScriptCache",
"blob_storage",
"Crashpad",
"Crash Reports",
"BrowserMetrics",
"optimization_guide_model_store",
"optimization_guide_hint_cache_store",
"Safe Browsing",
"Safe Browsing Network",
"component_crx_cache",
"extensions_crx_cache",
"Download Service",
"Site Characteristics Database",
"shared_proto_db",
"segmentation_platform",
"Sync App Settings",
// SNSS command logs replay the source machine's windows and can embed
// absolute local paths in PageState blobs.
"Sessions",
"Session Storage",
];
/// Exact file names that are per-machine, per-run, or regenerated.
const SKIP_FILES: &[&str] = &[
"LOCK",
"LOG",
"LOG.old",
"SingletonLock",
"SingletonCookie",
"SingletonSocket",
"RunningChromeVersion",
"Last Version",
"first_party_sets.db",
".DS_Store",
"Thumbs.db",
// The account-bound part of `Sync Data/`. The rest of that directory is the
// local DataTypeStore — Reading List, Saved Tab Groups and friends, which
// exist for users who never signed in — so the folder itself is carried.
"Nigori.bin",
// Signed-in ephemeral twins of the real stores. They are wiped on sign-out,
// and the imported profile will not be signed in.
"Login Data For Account",
"Login Data For Account-journal",
"Account Web Data",
"Account Web Data-journal",
];
/// Suffixes that belong to a database we snapshot separately, or to scratch
/// state. Copying a `-wal` next to a vacuumed main file actively corrupts it.
const SKIP_SUFFIXES: &[&str] = &["-journal", "-wal", "-shm", ".tmp", ".old", ".bak.tmp"];
/// SQLite stores worth a consistent snapshot. Anything not listed is copied
/// byte-for-byte, which is correct for JSON, LevelDB and unpacked CRXs.
const SQLITE_FILES: &[&str] = &[
"Cookies",
"History",
"Favicons",
"Top Sites",
"Shortcuts",
"Login Data",
"Web Data",
"Affiliation Database",
"Network Action Predictor",
"DIPS",
"Trust Tokens",
"BudgetDatabase",
"AutofillStrikeDatabase",
"Reporting and NEL",
"SCT Auditing Pending Reports",
"Device Bound Sessions",
"MediaDeviceSalts",
"PreferredApps",
"heavy_ad_intervention_opt_out.db",
"SharedStorage",
"BrowsingTopicsSiteData",
"ClientCertificates",
"PersistentOriginTrials",
"Web Applications",
];
pub struct CopyOutcome {
pub bytes_copied: u64,
/// Names of stores that could not be snapshotted and were skipped rather
/// than copied in a corrupt state.
pub unreadable_stores: Vec<String>,
}
fn is_skipped_dir(relative: &Path) -> bool {
let normalized = relative.to_string_lossy().replace('\\', "/");
SKIP_DIRS.iter().any(|skip| {
normalized == *skip
|| normalized.ends_with(&format!("/{skip}"))
// `BrowserMetrics-spare.pma` and friends.
|| normalized.starts_with(&format!("{skip}-"))
})
}
fn is_skipped_file(name: &str) -> bool {
SKIP_FILES.contains(&name)
|| SKIP_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
|| name.starts_with("BrowserMetrics")
}
/// Copy the source's permission bits onto a file we produced ourselves.
///
/// `fs::copy` already preserves the mode, but `VACUUM INTO` lets SQLite create
/// the destination at its own default (0644). Cookies, Login Data and Web Data
/// are 0600 in both the source browser and Wayfern, and an import must not be
/// the step that widens them.
#[cfg(unix)]
fn mirror_mode(source: &Path, dest: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(source) {
let mode = metadata.permissions().mode() & 0o777;
let _ = fs::set_permissions(dest, fs::Permissions::from_mode(mode));
}
}
#[cfg(not(unix))]
fn mirror_mode(_source: &Path, _dest: &Path) {}
/// Create a directory owner-only, matching what Chromium gives a profile.
fn create_private_dir(path: &Path) -> std::io::Result<()> {
fs::create_dir_all(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
}
Ok(())
}
/// Take a consistent snapshot of a SQLite database.
///
/// Returns `Ok(false)` when the file is not actually SQLite (an empty
/// placeholder, say), so the caller can fall back to a plain copy.
fn vacuum_into(source: &Path, dest: &Path) -> Result<bool, String> {
use rusqlite::{Connection, OpenFlags};
let conn = match Connection::open_with_flags(
source,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
) {
Ok(conn) => conn,
Err(e) => return Err(format!("open failed: {e}")),
};
// Confirm it really is a database before trusting VACUUM's error reporting.
if conn
.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
r.get::<_, i64>(0)
})
.is_err()
{
return Ok(false);
}
if dest.exists() {
fs::remove_file(dest).map_err(|e| format!("could not replace destination: {e}"))?;
}
// `VACUUM INTO` needs the path as a SQL string literal; single quotes are
// the only character that can break out of one.
let target = dest.to_string_lossy().replace('\'', "''");
conn
.execute_batch(&format!("VACUUM INTO '{target}'"))
.map_err(|e| format!("VACUUM INTO failed: {e}"))?;
mirror_mode(source, dest);
Ok(true)
}
/// Copy `source` (a Chromium profile directory) into `dest`, skipping caches
/// and snapshotting databases.
pub fn copy_profile_tree(source: &Path, dest: &Path) -> Result<CopyOutcome, String> {
let mut outcome = CopyOutcome {
bytes_copied: 0,
unreadable_stores: Vec::new(),
};
create_private_dir(dest).map_err(|e| format!("Failed to create {}: {e}", dest.display()))?;
copy_dir(source, dest, Path::new(""), &mut outcome)?;
Ok(outcome)
}
fn copy_dir(
source: &Path,
dest: &Path,
relative: &Path,
outcome: &mut CopyOutcome,
) -> Result<(), String> {
let entries =
fs::read_dir(source).map_err(|e| format!("Failed to read {}: {e}", source.display()))?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let child_relative = relative.join(name);
let source_path = entry.path();
let dest_path = dest.join(name);
// Symlinks are followed nowhere: Chromium writes them for the singleton
// lock, and a copied one would point at the source machine.
let metadata = match fs::symlink_metadata(&source_path) {
Ok(m) => m,
Err(_) => continue,
};
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
if is_skipped_dir(&child_relative) {
continue;
}
create_private_dir(&dest_path)
.map_err(|e| format!("Failed to create {}: {e}", dest_path.display()))?;
copy_dir(&source_path, &dest_path, &child_relative, outcome)?;
continue;
}
if is_skipped_file(name) {
continue;
}
if SQLITE_FILES.contains(&name) {
match vacuum_into(&source_path, &dest_path) {
Ok(true) => {
outcome.bytes_copied += fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
continue;
}
Ok(false) => {
// Not a database after all; fall through to a byte copy.
}
Err(e) => {
// A store we cannot snapshot is a store we must not copy: a torn
// copy is deleted by Chromium on open, which looks identical to
// "the import silently lost my data".
log::warn!("Skipping unreadable store {}: {e}", source_path.display());
outcome.unreadable_stores.push(name.to_string());
continue;
}
}
}
match fs::copy(&source_path, &dest_path) {
Ok(bytes) => outcome.bytes_copied += bytes,
Err(e) => log::warn!("Failed to copy {}: {e}", source_path.display()),
}
}
Ok(())
}
/// Every `Default/`-level store that holds real user data, for reporting.
pub fn count_leveldb_origins(leveldb_dir: &Path) -> usize {
// Counting keys would mean linking a LevelDB implementation. The number of
// `.ldb`/`.log` segments is a stable proxy for "there is data here", which
// is all the report claims.
let Ok(entries) = fs::read_dir(leveldb_dir) else {
return 0;
};
entries
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.ends_with(".ldb") || n.ends_with(".log"))
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use tempfile::TempDir;
fn touch(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
#[test]
fn caches_are_not_copied() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Cache").join("data_0"), &[0u8; 4096]);
touch(
&source.join("Code Cache").join("js").join("x"),
&[0u8; 4096],
);
touch(
&source.join("Service Worker").join("CacheStorage").join("y"),
&[0u8; 4096],
);
touch(
&source
.join("Service Worker")
.join("Database")
.join("CURRENT"),
b"MANIFEST-000001\n",
);
copy_profile_tree(&source, &dest).unwrap();
assert!(dest.join("Preferences").exists());
assert!(!dest.join("Cache").exists());
assert!(!dest.join("Code Cache").exists());
assert!(!dest.join("Service Worker").join("CacheStorage").exists());
assert!(
dest.join("Service Worker").join("Database").exists(),
"the Service Worker registry is real data and must survive"
);
}
#[test]
fn lock_and_journal_files_are_not_copied() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(
&source.join("Local Storage").join("leveldb").join("LOCK"),
b"",
);
touch(
&source.join("Local Storage").join("leveldb").join("CURRENT"),
b"MANIFEST-000001\n",
);
touch(&source.join("History-journal"), b"junk");
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest
.join("Local Storage")
.join("leveldb")
.join("LOCK")
.exists());
assert!(dest
.join("Local Storage")
.join("leveldb")
.join("CURRENT")
.exists());
assert!(!dest.join("History-journal").exists());
}
#[test]
fn sqlite_stores_are_snapshotted_and_stay_queryable() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("History");
let conn = Connection::open(&db).unwrap();
conn
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT); INSERT INTO urls(url) VALUES('https://example.com');")
.unwrap();
drop(conn);
copy_profile_tree(&source, &dest).unwrap();
let copied = Connection::open(dest.join("History")).unwrap();
let count: i64 = copied
.query_row("SELECT count(*) FROM urls", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn snapshot_captures_uncheckpointed_wal_content() {
// The whole reason for VACUUM INTO: a running browser leaves recent writes
// in the WAL, and a plain file copy loses them.
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("History");
let conn = Connection::open(&db).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT);")
.unwrap();
conn
.execute("INSERT INTO urls(url) VALUES('https://in-wal.example')", [])
.unwrap();
// Deliberately do not checkpoint or close: this is the live-browser shape.
copy_profile_tree(&source, &dest).unwrap();
drop(conn);
let copied = Connection::open(dest.join("History")).unwrap();
let url: String = copied
.query_row("SELECT url FROM urls", [], |r| r.get(0))
.unwrap();
assert_eq!(url, "https://in-wal.example");
assert!(
!dest.join("History-wal").exists(),
"a stale -wal beside a vacuumed file corrupts it"
);
}
#[test]
fn symlinks_are_never_followed() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
let outside = dir.path().join("outside.txt");
touch(&outside, b"secret");
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, source.join("SingletonLock")).unwrap();
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest.join("SingletonLock").exists());
}
#[test]
fn account_scoped_stores_are_dropped() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Login Data For Account"), b"x");
touch(&source.join("Sync Data").join("Nigori.bin"), b"x");
touch(
&source.join("Sync Data").join("LevelDB").join("CURRENT"),
b"x",
);
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest.join("Login Data For Account").exists());
assert!(
!dest.join("Sync Data").join("Nigori.bin").exists(),
"the Nigori keyset is bound to a Google account"
);
assert!(
dest
.join("Sync Data")
.join("LevelDB")
.join("CURRENT")
.exists(),
"the rest of Sync Data is local state such as the reading list"
);
}
#[test]
#[cfg(unix)]
fn copied_databases_keep_the_browsers_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("Cookies");
let conn = rusqlite::Connection::open(&db).unwrap();
conn
.execute_batch("CREATE TABLE cookies(x INTEGER);")
.unwrap();
drop(conn);
fs::set_permissions(&db, fs::Permissions::from_mode(0o600)).unwrap();
copy_profile_tree(&source, &dest).unwrap();
// VACUUM INTO would otherwise create the snapshot at SQLite's default 0644.
let mode = fs::metadata(dest.join("Cookies"))
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"an import must not widen a cookie store"
);
let dir_mode = fs::metadata(&dest).unwrap().permissions().mode();
assert_eq!(dir_mode & 0o777, 0o700);
}
#[test]
fn unreadable_store_is_reported_not_copied_corrupt() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
// A file that opens as SQLite but is structurally broken.
touch(
&source.join("Cookies"),
b"SQLite format 3\0garbage-not-a-db",
);
let outcome = copy_profile_tree(&source, &dest).unwrap();
assert!(
!dest.join("Cookies").exists() || outcome.unreadable_stores.is_empty(),
"a store is either snapshotted cleanly or skipped and reported"
);
}
#[test]
fn non_sqlite_file_with_a_store_name_still_copies() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Top Sites"), b"");
copy_profile_tree(&source, &dest).unwrap();
assert!(dest.join("Top Sites").exists());
}
}
+398
View File
@@ -0,0 +1,398 @@
//! Recovering the *source* browser's os_crypt key.
//!
//! Every Chromium-family browser seals cookies, passwords and payment data with
//! a key held outside the profile: the macOS Keychain, a DPAPI blob in
//! `Local State`, or the Freedesktop secret service. Import has to open that
//! lock before it can re-seal anything with Wayfern's portable key
//! ([`super::os_crypt::TargetKey`]).
//!
//! Failure here is never fatal. A declined Keychain prompt or a locked keyring
//! degrades to "everything except the secrets came across", recorded as a
//! warning, because a partial profile is worth far more than a failed import.
#[cfg(target_os = "windows")]
use super::os_crypt::CryptoKey;
use super::os_crypt::SourceKeyring;
#[cfg(target_os = "macos")]
use super::os_crypt::MAC_ITERATIONS;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use super::os_crypt::{derive_key, CryptoKey};
#[cfg(target_os = "linux")]
use super::os_crypt::{POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS};
use super::report::warning;
use std::path::Path;
/// Keychain / secret-service identities to try for a source family, most
/// specific first.
///
/// Trying several is safe and costs nothing: a lookup for a service that does
/// not exist fails without prompting, so at most one dialog appears — the one
/// for the item that is actually there. That is what lets a single `chromium`
/// family key cover both Google Chrome and vanilla Chromium, which share a
/// detection entry but not a Keychain item.
// Consulted by the Keychain and secret-service lookups. Windows resolves the
// key through DPAPI against the profile's own Local State, so it never needs
// to guess a brand.
#[allow(dead_code)]
fn brand_candidates(family: &str, source_path: &Path) -> Vec<&'static str> {
let path = source_path.to_string_lossy();
let mut brands: Vec<&'static str> = match family {
"chrome-beta" => vec!["Chrome Beta", "Chrome"],
"chrome-dev" => vec!["Chrome Dev", "Chrome"],
"chrome-canary" => vec!["Chrome Canary", "Chrome"],
"brave" => vec!["Brave", "Brave Browser"],
"brave-beta" => vec!["Brave Beta", "Brave Browser", "Brave"],
"brave-nightly" => vec!["Brave Nightly", "Brave Browser", "Brave"],
"edge" => vec!["Microsoft Edge", "Chromium"],
"edge-beta" => vec!["Microsoft Edge Beta", "Microsoft Edge"],
"edge-dev" => vec!["Microsoft Edge Dev", "Microsoft Edge"],
"vivaldi" => vec!["Vivaldi", "Chromium"],
"opera" => vec!["Opera", "Chromium"],
"opera-gx" => vec!["Opera GX", "Opera", "Chromium"],
"arc" => vec!["Arc", "Chromium"],
"yandex" => vec!["Yandex", "Yandex Browser", "Chromium"],
// "chromium" covers both Google Chrome and upstream Chromium; the install
// path is the only thing that tells them apart.
_ => vec!["Chrome", "Chromium"],
};
if (family.is_empty() || family == "chromium")
&& path.contains("Chromium")
&& !path.contains("Google")
{
brands = vec!["Chromium", "Chrome"];
}
brands
}
/// Recover whatever key material the source browser used.
///
/// `source_user_data_dir` is the directory holding `Local State` (the parent of
/// the profile directory), which is where Windows keeps its wrapped key. It is
/// `None` when the user pointed at a bare profile folder with no parent we can
/// trust.
pub fn recover_source_keys(
family: &str,
source_path: &Path,
source_user_data_dir: Option<&Path>,
report: &mut super::report::ProfileImportReport,
) -> SourceKeyring {
let mut keyring = SourceKeyring::default();
#[cfg(target_os = "macos")]
{
let _ = source_user_data_dir;
for brand in brand_candidates(family, source_path) {
match macos_keychain_password(brand) {
Ok(Some(password)) => {
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(&password, MAC_ITERATIONS)));
log::info!("Recovered os_crypt password for '{brand} Safe Storage'");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Keychain lookup for '{brand} Safe Storage' failed: {e}");
break;
}
}
}
}
#[cfg(target_os = "windows")]
{
let _ = source_path;
if let Some(dir) = source_user_data_dir {
match windows_local_state_key(dir) {
Ok(Some(key)) => keyring.v10 = Some(CryptoKey::Aes256Gcm(key)),
Ok(None) => {}
Err(e) => log::warn!("DPAPI key recovery failed: {e}"),
}
if windows_has_app_bound_key(dir) {
// Recorded up front: the cookie store will be full of `v20` records
// and the user deserves to know why before they see the count.
report.warn(warning::APP_BOUND_ENCRYPTED);
}
}
}
#[cfg(target_os = "linux")]
{
let _ = source_user_data_dir;
// A profile can hold both tags at once, so populate both slots rather than
// choosing one. v10 is always available: it is a hardcoded password.
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(
POSIX_FALLBACK_PASSWORD,
POSIX_ITERATIONS,
)));
for brand in brand_candidates(family, source_path) {
match linux_secret_service_password(brand) {
Ok(Some(password)) => {
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(
&password,
POSIX_ITERATIONS,
)));
log::info!("Recovered os_crypt secret for '{brand} Safe Storage'");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Secret service lookup for '{brand} Safe Storage' failed: {e}");
break;
}
}
}
}
if keyring.is_empty() {
report.warn(warning::SECRETS_NOT_MIGRATED);
}
// Silence unused-parameter warnings on platforms that do not use every arg.
let _ = (family, source_path, source_user_data_dir);
keyring
}
/// How long to wait on a keyring before giving up.
///
/// Both backends can put a dialog in front of the user — macOS asks whether
/// Donut may read another app's Keychain item, and an unlocked-on-demand
/// keyring prompts on Linux. That is fine interactively, but an import driven
/// over REST or MCP would otherwise wedge forever with nobody at the screen.
/// Long enough for a person to notice and click; short enough that automation
/// recovers into "secrets not migrated", which is merely a partial import.
#[cfg(any(target_os = "macos", target_os = "linux"))]
const KEYRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Run a keyring lookup on its own OS thread, bounded by [`KEYRING_TIMEOUT`].
///
/// Off-thread rather than inline for two reasons: import already runs inside
/// `spawn_blocking`, and zbus's blocking API drives a private tokio runtime, so
/// keeping it off a runtime-owned thread sidesteps any nested-runtime question;
/// and it turns a panic or a stuck IPC call into a recoverable warning instead
/// of a failed import.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn run_keyring_lookup<F>(what: &str, lookup: F) -> Result<Option<Vec<u8>>, String>
where
F: FnOnce() -> Result<Option<Vec<u8>>, String> + Send + std::panic::UnwindSafe + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result =
std::panic::catch_unwind(lookup).unwrap_or_else(|_| Err("lookup panicked".to_string()));
let _ = tx.send(result);
});
match rx.recv_timeout(KEYRING_TIMEOUT) {
Ok(result) => result,
Err(_) => Err(format!("{what} did not respond")),
}
}
#[cfg(target_os = "macos")]
fn macos_keychain_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
let brand = brand.to_string();
run_keyring_lookup("keychain", move || macos_keychain_lookup(&brand))
}
#[cfg(target_os = "macos")]
fn macos_keychain_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
use security_framework::passwords::get_generic_password;
let service = format!("{brand} Safe Storage");
match get_generic_password(&service, brand) {
Ok(password) => Ok(Some(password)),
Err(e) => {
// errSecItemNotFound: this brand simply is not installed. Anything else
// (notably errSecAuthFailed / errSecUserCanceled when the user declines
// the access dialog) is a real failure worth surfacing.
if e.code() == -25300 {
Ok(None)
} else {
Err(e.to_string())
}
}
}
}
#[cfg(target_os = "windows")]
fn read_local_state_os_crypt(dir: &Path) -> Option<serde_json::Value> {
let raw = std::fs::read_to_string(dir.join("Local State")).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed.get("os_crypt").cloned()
}
#[cfg(target_os = "windows")]
fn windows_has_app_bound_key(dir: &Path) -> bool {
read_local_state_os_crypt(dir)
.and_then(|v| {
v.get("app_bound_encrypted_key")
.and_then(|k| k.as_str().map(str::to_string))
})
.is_some_and(|k| !k.is_empty())
}
#[cfg(target_os = "windows")]
fn windows_local_state_key(dir: &Path) -> Result<Option<[u8; 32]>, String> {
use base64::Engine;
let Some(os_crypt) = read_local_state_os_crypt(dir) else {
return Ok(None);
};
let Some(encoded) = os_crypt.get("encrypted_key").and_then(|k| k.as_str()) else {
return Ok(None);
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| format!("encrypted_key is not valid base64: {e}"))?;
// The blob is "DPAPI" || CryptProtectData(key).
const DPAPI_PREFIX: &[u8] = b"DPAPI";
if !decoded.starts_with(DPAPI_PREFIX) {
return Err("encrypted_key is missing the DPAPI header".to_string());
}
let unwrapped = dpapi_unprotect(&decoded[DPAPI_PREFIX.len()..])?;
let key: [u8; 32] = unwrapped
.as_slice()
.try_into()
.map_err(|_| format!("expected a 32-byte AES key, got {} bytes", unwrapped.len()))?;
Ok(Some(key))
}
#[cfg(target_os = "windows")]
fn dpapi_unprotect(ciphertext: &[u8]) -> Result<Vec<u8>, String> {
use windows::Win32::Foundation::LocalFree;
use windows::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB};
// `pdatain` is `*const CRYPT_INTEGER_BLOB`: DPAPI only reads the input blob,
// so a shared reference is what the signature wants.
let input = CRYPT_INTEGER_BLOB {
cbData: ciphertext.len() as u32,
pbData: ciphertext.as_ptr() as *mut u8,
};
let mut output = CRYPT_INTEGER_BLOB::default();
// SAFETY: `input` points at a live slice for the duration of the call, and
// `output` is freed via LocalFree exactly once below, as the API requires.
unsafe {
CryptUnprotectData(&input, None, None, None, None, 0, &mut output)
.map_err(|e| format!("CryptUnprotectData failed: {e}"))?;
let plaintext = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
let _ = LocalFree(Some(windows::Win32::Foundation::HLOCAL(
output.pbData as *mut core::ffi::c_void,
)));
Ok(plaintext)
}
}
#[cfg(target_os = "linux")]
fn linux_secret_service_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
let brand = brand.to_string();
run_keyring_lookup("secret service", move || {
linux_secret_service_lookup(&brand)
})
}
#[cfg(target_os = "linux")]
fn linux_secret_service_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
use secret_service::blocking::SecretService;
use secret_service::EncryptionType;
use std::collections::HashMap;
let service =
SecretService::connect(EncryptionType::Dh).map_err(|e| format!("no secret service: {e}"))?;
let collection = service
.get_default_collection()
.map_err(|e| format!("no default collection: {e}"))?;
if collection.is_locked().unwrap_or(true) {
collection
.unlock()
.map_err(|e| format!("keyring is locked: {e}"))?;
}
// Match on the item's LABEL, not on its `application` attribute.
//
// `freedesktop_secret_key_provider.cc` stores two attributes —
// `application: kAppName` and `xdg:schema` — and sets the label to
// `kKeyName`, which is always "<Brand> Safe Storage". `kAppName` is a
// per-fork branding string ("chrome", "chromium", …) that we cannot derive
// from a display name: lowercasing "Microsoft Edge" gives "microsoft edge",
// which matches nothing, and the search would silently return zero items.
// The label is the one identifier that is the same across every fork and is
// exactly the string we already build for the macOS Keychain.
let label = format!("{brand} Safe Storage");
// The schema attribute narrows the scan to os_crypt secrets; it is shared by
// every Chromium fork, so it costs nothing in portability.
let mut attributes = HashMap::new();
attributes.insert("xdg:schema", "chrome_libsecret_os_crypt_password_v2");
let mut items = collection
.search_items(attributes)
.map_err(|e| format!("search failed: {e}"))?;
if items.is_empty() {
// Older Chromium releases used a v1 schema, and some forks omit it.
items = collection
.get_all_items()
.map_err(|e| format!("could not list items: {e}"))?;
}
for item in &items {
if item.get_label().is_ok_and(|found| found == label) {
return item
.get_secret()
.map(Some)
.map_err(|e| format!("could not read secret: {e}"));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn chromium_family_disambiguates_chrome_from_chromium_by_path() {
let chrome = PathBuf::from("/Users/x/Library/Application Support/Google/Chrome/Default");
assert_eq!(brand_candidates("chromium", &chrome)[0], "Chrome");
let chromium = PathBuf::from("/Users/x/Library/Application Support/Chromium/Default");
assert_eq!(brand_candidates("chromium", &chromium)[0], "Chromium");
}
#[test]
fn every_brand_falls_back_to_a_second_candidate() {
// A single candidate means one wrong guess loses the secrets entirely, so
// each family must offer a fallback identity.
for family in [
"chrome-beta",
"chrome-dev",
"chrome-canary",
"brave",
"edge",
"vivaldi",
"opera",
"opera-gx",
"arc",
"yandex",
"chromium",
] {
let candidates = brand_candidates(family, Path::new("/tmp/profile"));
assert!(
candidates.len() >= 2,
"{family} needs a fallback brand candidate"
);
}
}
#[test]
fn unknown_family_still_yields_candidates() {
let candidates = brand_candidates("something-new", Path::new("/tmp/profile"));
assert!(!candidates.is_empty());
}
}
+380
View File
@@ -0,0 +1,380 @@
//! Working out what the user pointed at, and where its files have to land.
//!
//! Two layout facts drive everything here:
//!
//! 1. Donut launches with `--user-data-dir` and no `--profile-directory`, so
//! Chromium reads `<user-data-dir>/Default/` (`chrome_constants.cc`
//! `kInitialProfile`). A source *profile* directory therefore has to be
//! copied one level down, not onto the root.
//! 2. Network state (`Cookies`, `TransportSecurity`, …) lives in
//! `Default/Network/` on Windows and in `Default/` everywhere else. That
//! split is not cosmetic: `kTriggerNetworkDataMigration` is enabled by
//! default only on Windows, and on the other platforms Chromium actively
//! redirects reads back to `Default/`. A profile exported from Windows is
//! invisible on macOS until its files are moved up, and vice versa.
use std::path::{Path, PathBuf};
/// Files Chromium keeps under `Default/Network/` on Windows and directly under
/// `Default/` on macOS and Linux.
pub const NETWORK_DATA_FILES: &[&str] = &[
"Cookies",
"Cookies-journal",
"Network Persistent State",
"Reporting and NEL",
"SCT Auditing Pending Reports",
"Trust Tokens",
"Trust Tokens-journal",
"TransportSecurity",
"Device Bound Sessions",
"Device Bound Sessions-journal",
];
/// What the user handed us.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceKind {
/// A profile directory (holds `Preferences`): `.../Chrome/Default`.
ProfileDir,
/// A user-data directory whose profile lives at its root — Opera's layout.
RootProfileUserDataDir,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceShape {
pub kind: SourceKind,
/// The directory holding `Preferences` — the content that becomes `Default/`.
pub profile_dir: PathBuf,
/// The directory holding `Local State`, when there is one. Windows keeps the
/// DPAPI-wrapped os_crypt key there, so losing it loses every secret.
pub user_data_dir: Option<PathBuf>,
}
/// Why a directory cannot be imported.
#[derive(Debug, PartialEq, Eq)]
pub enum RejectReason {
/// Recognisably a Gecko profile. Worth naming explicitly: silently returning
/// "nothing found" for a Firefox folder is what made import feel broken.
Firefox,
/// Not a browser profile we recognise at all.
NotChromium,
}
/// Markers that identify a real Chromium profile directory. `Preferences` is
/// the usual one, but a profile whose prefs were wiped still has data worth
/// carrying, so any of these counts.
const CHROMIUM_PROFILE_MARKERS: &[&str] = &[
"Preferences",
"Secure Preferences",
"History",
"Cookies",
"Bookmarks",
"Web Data",
"Login Data",
];
fn looks_like_chromium_profile(dir: &Path) -> bool {
CHROMIUM_PROFILE_MARKERS
.iter()
.any(|marker| dir.join(marker).exists())
// Windows-layout profiles keep Cookies one level down.
|| dir.join("Network").join("Cookies").exists()
}
fn looks_like_firefox_profile(dir: &Path) -> bool {
// Any one of these alone can appear elsewhere; together they are conclusive.
let markers = ["prefs.js", "places.sqlite", "cookies.sqlite", "key4.db"];
markers.iter().filter(|m| dir.join(m).exists()).count() >= 2
}
/// Classify an import source, or explain why it cannot be one.
pub fn classify(source: &Path) -> Result<SourceShape, RejectReason> {
if looks_like_firefox_profile(source) {
return Err(RejectReason::Firefox);
}
if !looks_like_chromium_profile(source) {
return Err(RejectReason::NotChromium);
}
// A directory that holds both profile markers and `Local State` is Opera's
// root-profile layout: the user-data dir and the profile are the same place.
let kind = if source.join("Local State").exists() {
SourceKind::RootProfileUserDataDir
} else {
SourceKind::ProfileDir
};
let user_data_dir = match kind {
SourceKind::RootProfileUserDataDir => Some(source.to_path_buf()),
// For `.../Chrome/Default`, `Local State` is in `.../Chrome`. Only accept
// the parent if it really holds one, so a profile copied to a random
// folder does not make us read a stranger's `Local State`.
SourceKind::ProfileDir => source.parent().and_then(|parent| {
if parent.join("Local State").exists() {
return Some(parent.to_path_buf());
}
// Opera keeps its extra profiles at `<user-data-dir>/_side_profiles/<id>`
// but still launches them against the same user-data dir, so the
// DPAPI-wrapped os_crypt key sits one further level up. Without this,
// every Opera side profile imports on Windows with no secrets at all.
if parent.file_name() == Some(std::ffi::OsStr::new("_side_profiles")) {
return parent
.parent()
.filter(|root| root.join("Local State").exists())
.map(Path::to_path_buf);
}
None
}),
};
Ok(SourceShape {
kind,
profile_dir: source.to_path_buf(),
user_data_dir,
})
}
/// Move network data into the position the *host* Chromium build reads from.
///
/// Host, not source: the files were written by whatever browser produced them,
/// but they will be read by Wayfern running here. Getting this backwards is a
/// silent, total cookie loss on any cross-platform import.
pub fn normalize_network_dir(default_dir: &Path) -> std::io::Result<()> {
let network_dir = default_dir.join("Network");
let (from, to) = if cfg!(target_os = "windows") {
(default_dir.to_path_buf(), network_dir.clone())
} else {
(network_dir.clone(), default_dir.to_path_buf())
};
if !from.exists() {
return Ok(());
}
for name in NETWORK_DATA_FILES {
let src = from.join(name);
if !src.is_file() {
continue;
}
std::fs::create_dir_all(&to)?;
let dest = to.join(name);
if dest.exists() {
// Both positions hold the file. The one in the source position is the
// stale duplicate: on Windows, Chromium's migration would copy it over
// the newer file ("overwrite the new file with the old file even if it
// exists already", network_sandbox.cc), so it has to go.
std::fs::remove_file(&src)?;
continue;
}
std::fs::rename(&src, &dest).or_else(|_| {
// Rename across devices can fail even within one tree on some setups.
std::fs::copy(&src, &dest).and_then(|_| std::fs::remove_file(&src))?;
Ok::<(), std::io::Error>(())
})?;
}
if !cfg!(target_os = "windows") {
// Chromium's migration checkpoint, and the reason an otherwise-correct
// move is not enough. `network_sandbox.cc:478` treats the presence of
// `NetworkDataMigrated` as proof the migration already ran, keeps the (now
// empty) `Network/` as the data directory, and then `CleanUpOldData` at
// `:536-540` DELETES the files we just moved up into `Default/`. A profile
// exported from Windows would lose every cookie on first launch.
let _ = std::fs::remove_file(network_dir.join("NetworkDataMigrated"));
// Leave no empty `Network/` behind: harmless, but it makes a profile look
// like it still holds network state.
if network_dir.is_dir() && std::fs::read_dir(&network_dir)?.next().is_none() {
let _ = std::fs::remove_dir(&network_dir);
}
}
Ok(())
}
/// Where the cookie store ends up for the host platform.
pub fn host_cookie_path(default_dir: &Path) -> PathBuf {
if cfg!(target_os = "windows") {
default_dir.join("Network").join("Cookies")
} else {
default_dir.join("Cookies")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, b"x").unwrap();
}
#[test]
fn plain_profile_dir_is_classified_without_a_user_data_dir() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("Default");
touch(&profile.join("Preferences"));
let shape = classify(&profile).expect("should classify");
assert_eq!(shape.kind, SourceKind::ProfileDir);
assert_eq!(shape.user_data_dir, None);
}
#[test]
fn profile_dir_finds_local_state_in_its_parent() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("Default");
touch(&profile.join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(&profile).expect("should classify");
// Windows keeps the wrapped os_crypt key here; missing it means no secrets.
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
}
#[test]
fn opera_root_layout_is_its_own_user_data_dir() {
let dir = TempDir::new().unwrap();
touch(&dir.path().join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(dir.path()).expect("should classify");
assert_eq!(shape.kind, SourceKind::RootProfileUserDataDir);
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
}
#[test]
fn firefox_profile_is_rejected_by_name() {
let dir = TempDir::new().unwrap();
touch(&dir.path().join("prefs.js"));
touch(&dir.path().join("places.sqlite"));
assert_eq!(classify(dir.path()), Err(RejectReason::Firefox));
}
#[test]
fn empty_directory_is_rejected() {
let dir = TempDir::new().unwrap();
assert_eq!(classify(dir.path()), Err(RejectReason::NotChromium));
}
#[test]
fn windows_layout_profile_is_recognised_without_root_markers() {
// A profile whose only surviving data is Windows-layout cookies.
let dir = TempDir::new().unwrap();
touch(&dir.path().join("Network").join("Cookies"));
assert!(classify(dir.path()).is_ok());
}
#[test]
fn opera_side_profile_finds_local_state_two_levels_up() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("_side_profiles").join("gaming");
touch(&profile.join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(&profile).expect("should classify");
assert_eq!(
shape.user_data_dir.as_deref(),
Some(dir.path()),
"Windows keeps the os_crypt key in the root Local State, not beside the profile"
);
}
#[test]
fn a_profile_in_an_unrelated_folder_does_not_adopt_a_strangers_local_state() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("_side_profiles").join("gaming");
touch(&profile.join("Preferences"));
// No Local State anywhere above it.
let shape = classify(&profile).expect("should classify");
assert_eq!(shape.user_data_dir, None);
}
#[test]
fn migration_checkpoint_is_removed_so_chromium_does_not_delete_the_moved_files() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Network").join("Cookies"));
touch(&default_dir.join("Network").join("NetworkDataMigrated"));
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
if !cfg!(target_os = "windows") {
assert!(
!default_dir
.join("Network")
.join("NetworkDataMigrated")
.exists(),
"the checkpoint makes Chromium delete the files we just moved up"
);
assert!(!default_dir.join("Network").exists());
}
}
#[test]
fn network_files_are_moved_into_the_host_position() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
// Seed the file in the position the host does NOT read from.
if cfg!(target_os = "windows") {
touch(&default_dir.join("Cookies"));
} else {
touch(&default_dir.join("Network").join("Cookies"));
}
normalize_network_dir(&default_dir).unwrap();
assert!(
host_cookie_path(&default_dir).is_file(),
"cookies must end up where this platform's Chromium reads them"
);
}
#[test]
fn stale_duplicate_in_the_source_position_is_removed() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Cookies"));
touch(&default_dir.join("Network").join("Cookies"));
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
let stale = if cfg!(target_os = "windows") {
default_dir.join("Cookies")
} else {
default_dir.join("Network").join("Cookies")
};
assert!(
!stale.exists(),
"the duplicate would be copied over the live file by Chromium's migration"
);
}
#[test]
fn normalize_is_idempotent() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Network").join("Cookies"));
normalize_network_dir(&default_dir).unwrap();
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
}
#[test]
fn normalize_on_a_profile_with_no_network_data_is_a_no_op() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
normalize_network_dir(&default_dir).unwrap();
assert!(!host_cookie_path(&default_dir).exists());
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Turning someone else's browser profile into one Wayfern will actually load.
//!
//! The old importer copied a source profile directory verbatim onto the new
//! profile's `--user-data-dir`. Chromium reads `<user-data-dir>/Default/`, so
//! every imported file sat one level above where the browser looked and the
//! profile came up empty — and even in the right place the secrets would not
//! have opened, because they are sealed with a key held in the source
//! machine's Keychain / DPAPI / secret service that Wayfern never consults.
//!
//! This module does the whole job: classify the source, recover its key, copy
//! with consistent database snapshots, put the files where Chromium reads them,
//! re-seal every secret with Wayfern's portable key, and report exactly what
//! came across.
pub mod copy;
pub mod keyring;
pub mod layout;
pub mod os_crypt;
pub mod report;
pub mod rewrite;
use layout::RejectReason;
use report::{warning, ProfileImportReport};
use std::path::Path;
/// The profile subdirectory Chromium reads when no `--profile-directory` is
/// passed (`chrome_constants.cc` `kInitialProfile`). Donut never passes one.
pub const INITIAL_PROFILE_DIR: &str = "Default";
/// Import `source` into `dest_user_data_dir`, which becomes the new profile's
/// `--user-data-dir`.
///
/// Never fails because part of the data could not be carried: partial results
/// plus an honest report beat an all-or-nothing import that leaves the user
/// with nothing and no explanation. It fails only when the source is not
/// importable at all, or when the target key cannot be established — without
/// that key, anything written would be unreadable forever.
pub fn import_into(
source: &Path,
dest_user_data_dir: &Path,
source_family: &str,
allow_running: bool,
) -> Result<ProfileImportReport, String> {
let shape = layout::classify(source).map_err(|reason| match reason {
RejectReason::Firefox => serde_json::json!({
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
"params": { "family": "Firefox" }
})
.to_string(),
RejectReason::NotChromium => serde_json::json!({
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
"params": { "family": "" }
})
.to_string(),
})?;
let mut report = ProfileImportReport::default();
if let Some(running) = running_source_browser(&shape) {
if !allow_running {
return Err(
serde_json::json!({
"code": "IMPORT_SOURCE_BROWSER_RUNNING",
"params": { "browser": running }
})
.to_string(),
);
}
// Databases are snapshotted transactionally, but LevelDB site data is
// copied as files and can be mid-write.
report.warn(warning::SOURCE_BROWSER_RUNNING);
}
// Mint the target key first. Everything after this point is written to be
// readable with it, and a profile whose key could not be persisted would
// lose every secret the first time the browser exits.
let target = os_crypt::TargetKey::ensure(dest_user_data_dir)?;
// Recover the source key before the copy: on macOS this may prompt, and
// asking before a multi-GB copy respects the user's time.
let source_keys = keyring::recover_source_keys(
source_family,
&shape.profile_dir,
shape.user_data_dir.as_deref(),
&mut report,
);
let default_dir = dest_user_data_dir.join(INITIAL_PROFILE_DIR);
let outcome = copy::copy_profile_tree(&shape.profile_dir, &default_dir)?;
report.bytes_copied = outcome.bytes_copied;
if !outcome.unreadable_stores.is_empty() {
report.warn(warning::STORE_UNREADABLE);
}
layout::normalize_network_dir(&default_dir)
.map_err(|e| format!("Failed to place network data: {e}"))?;
rewrite::finalize_profile(&default_dir, &source_keys, &target, &mut report);
Ok(report)
}
/// Is the browser that owns this profile currently running?
///
/// Matched on the profile path in the process command line rather than on the
/// executable name: the user may well have Chrome open on a *different*
/// profile, which is no reason to block the import.
fn running_source_browser(shape: &layout::SourceShape) -> Option<String> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let needle = shape
.user_data_dir
.as_deref()
.unwrap_or(&shape.profile_dir)
.to_string_lossy()
.to_string();
if needle.is_empty() {
return None;
}
for process in system.processes().values() {
let name = process.name().to_string_lossy().to_lowercase();
let looks_like_a_browser = name.contains("chrome")
|| name.contains("chromium")
|| name.contains("brave")
|| name.contains("edge")
|| name.contains("vivaldi")
|| name.contains("opera")
|| name.contains("arc")
|| name.contains("yandex");
if !looks_like_a_browser {
continue;
}
// Donut's own browser is Wayfern; never report it as the source.
if name.contains("wayfern") {
continue;
}
if process
.cmd()
.iter()
.any(|arg| arg.to_string_lossy().contains(&needle))
{
return Some(process.name().to_string_lossy().to_string());
}
}
None
}
/// Move a profile that an earlier build imported into the broken root layout
/// down into `Default/`, where the browser reads it.
///
/// Without this, everything those users imported stays stranded: their real
/// data sits at `profile/Cookies` while Wayfern reads and writes
/// `profile/Default/Cookies`. Their secrets remain unreadable — the source key
/// was never captured and cannot be recovered after the fact — but history,
/// bookmarks, extensions and site data become visible again.
///
/// Returns `Ok(true)` when a repair was performed.
pub fn repair_legacy_layout(user_data_dir: &Path) -> Result<bool, String> {
let default_dir = user_data_dir.join(INITIAL_PROFILE_DIR);
// The broken shape is exactly: profile markers at the root, and no `Default/`
// for the browser to have used instead.
let has_root_profile = user_data_dir.join("Preferences").exists()
|| user_data_dir.join("History").exists()
|| user_data_dir.join("Cookies").exists();
if !has_root_profile || default_dir.exists() {
return Ok(false);
}
// Root-level files that belong to the user-data dir, not to the profile.
const ROOT_LEVEL: &[&str] = &[
"Local State",
"os_crypt_key",
"First Run",
"Last Version",
"Variations",
"ChromeFeatureState",
"RunningChromeVersion",
"SingletonLock",
"SingletonCookie",
"SingletonSocket",
"user.js",
"metadata.json",
".donut-sync",
];
let staging = user_data_dir.join(".donut-import-repair");
if staging.exists() {
std::fs::remove_dir_all(&staging).map_err(|e| format!("Failed to clear staging: {e}"))?;
}
std::fs::create_dir_all(&staging).map_err(|e| format!("Failed to create staging: {e}"))?;
let entries =
std::fs::read_dir(user_data_dir).map_err(|e| format!("Failed to read profile: {e}"))?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if ROOT_LEVEL.contains(&name_str) || name_str == ".donut-import-repair" {
continue;
}
std::fs::rename(entry.path(), staging.join(name_str))
.map_err(|e| format!("Failed to relocate {name_str}: {e}"))?;
}
std::fs::rename(&staging, &default_dir)
.map_err(|e| format!("Failed to install {INITIAL_PROFILE_DIR}: {e}"))?;
// Now that the files are in the right place, put the network data where this
// platform reads it too.
let _ = layout::normalize_network_dir(&default_dir);
// And make sure the profile has a key, so the browser does not mint one
// mid-session and lose whatever it writes.
let _ = os_crypt::TargetKey::ensure(user_data_dir);
log::info!(
"Repaired legacy import layout at {} (moved profile content into {INITIAL_PROFILE_DIR}/)",
user_data_dir.display()
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
#[test]
fn import_places_everything_under_default() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("Chrome").join("Default");
let dest = dir.path().join("profile");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Bookmarks"), b"{\"roots\":{}}");
let report = import_into(&source, &dest, "chromium", true).expect("import");
assert!(
dest.join("Default").join("Preferences").exists(),
"Chromium reads Default/, not the user-data-dir root"
);
assert!(
!dest.join("Preferences").exists(),
"nothing profile-scoped belongs at the root"
);
assert!(dest.join(os_crypt::KEY_FILE_NAME).exists());
assert!(report.bytes_copied > 0);
}
#[test]
fn import_rejects_a_firefox_profile_by_name() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("xyz.default-release");
let dest = dir.path().join("profile");
touch(&source.join("prefs.js"), b"");
touch(&source.join("places.sqlite"), b"");
let err = import_into(&source, &dest, "firefox", true).expect_err("must reject");
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
assert!(
err.contains("Firefox"),
"the user needs to be told why, not just that it failed"
);
}
#[test]
fn import_rejects_an_arbitrary_folder() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("holiday-photos");
let dest = dir.path().join("profile");
touch(&source.join("IMG_0001.jpg"), b"not a profile");
let err = import_into(&source, &dest, "chromium", true).expect_err("must reject");
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
}
#[test]
fn import_is_rerunnable_over_the_same_destination() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("Default");
let dest = dir.path().join("profile");
touch(&source.join("Preferences"), b"{}");
import_into(&source, &dest, "chromium", true).expect("first");
let key = std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap();
import_into(&source, &dest, "chromium", true).expect("second");
assert_eq!(
std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap(),
key,
"re-running must not orphan what the first run encrypted"
);
}
#[test]
fn legacy_layout_is_repaired_into_default() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
// Exactly what the old importer produced.
touch(&profile.join("Preferences"), b"{}");
touch(&profile.join("History"), b"");
touch(
&profile
.join("Local Storage")
.join("leveldb")
.join("CURRENT"),
b"",
);
touch(&profile.join("Local State"), b"{}");
assert!(repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
assert!(profile.join("Default").join("History").exists());
assert!(profile
.join("Default")
.join("Local Storage")
.join("leveldb")
.join("CURRENT")
.exists());
assert!(
profile.join("Local State").exists(),
"Local State belongs to the user-data dir, not the profile"
);
assert!(profile.join(os_crypt::KEY_FILE_NAME).exists());
assert!(!profile.join(".donut-import-repair").exists());
}
#[test]
fn repair_leaves_a_healthy_profile_alone() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
touch(&profile.join("Default").join("Preferences"), b"{}");
touch(&profile.join("Local State"), b"{}");
assert!(!repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
assert!(!profile.join("Default").join("Default").exists());
}
#[test]
fn repair_is_a_no_op_on_an_empty_profile() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
std::fs::create_dir_all(&profile).unwrap();
assert!(!repair_legacy_layout(&profile).unwrap());
}
#[test]
fn repair_is_idempotent() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
touch(&profile.join("Preferences"), b"{}");
assert!(repair_legacy_layout(&profile).unwrap());
assert!(!repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
}
}
+568
View File
@@ -0,0 +1,568 @@
//! Key material for profile import.
//!
//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async`
//! key provider is patched to read (or mint) `<user-data-dir>/os_crypt_key`
//! instead, so a profile directory is self-contained and portable. See
//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`.
//!
//! That portability is exactly why an imported Chrome profile carries nothing:
//! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI
//! / the Freedesktop secret service, and Wayfern never looks there. Import has
//! to open the source's lock and re-seal everything with Wayfern's.
//!
//! The on-disk format is per-platform and NOT interchangeable, matching the
//! provider that owns each tag in the patched Chromium 151 tree:
//!
//! | Host | `os_crypt_key` | Derivation | Cipher | Tag |
//! |---------|---------------------|-------------------------------------|--------------|-------|
//! | macOS | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1003) | AES-128-CBC | `v10` |
//! | Linux | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1) | AES-128-CBC | `v11` |
//! | Windows | 32 raw bytes | none, the bytes are the key | AES-256-GCM | `v10` |
//!
//! Linux must write `v11`, not `v10`: `PosixKeyProvider` owns `v10` with the
//! hardcoded "peanuts" password and `Encryptor::DecryptData` dispatches on the
//! tag prefix, so a `v10` record on Linux would be decrypted with the wrong key
//! forever.
use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
// Windows stores the raw 32-byte key, so it neither encodes nor decodes
// base64; only the mac/Linux password paths below need the trait in scope.
#[cfg(not(target_os = "windows"))]
use base64::Engine;
use rand::RngExt;
use ring::pbkdf2;
use std::num::NonZeroU32;
use std::path::Path;
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
/// Chromium's fixed PBKDF2 salt for every CBC-based os_crypt provider.
// Only the CBC hosts derive a key; Windows uses the file's bytes directly.
#[allow(dead_code)]
pub const SALT: &[u8] = b"saltysalt";
/// Chromium's fixed CBC IV: sixteen spaces.
pub const CBC_IV: [u8; 16] = [b' '; 16];
/// AES-256-GCM nonce length, prepended to the ciphertext by `Encryptor::Key::Encrypt`.
const GCM_NONCE_LEN: usize = 12;
/// The `os_crypt_key` name, at the root of the user-data dir.
pub const KEY_FILE_NAME: &str = "os_crypt_key";
/// `PBKDF2-HMAC-SHA1(password = "", salt = "saltysalt", iterations = 1)`.
///
/// Chromium retries every failed AES-128-CBC decrypt with this key
/// (`encryptor.cc`, crbug.com/40055416) because profiles created while the
/// keyring was unavailable were sealed with an empty password. Import has to do
/// the same or those records look corrupt.
pub const EMPTY_PASSWORD_KEY: [u8; 16] = [
0xd0, 0xd0, 0xec, 0x9c, 0x7d, 0x77, 0xd4, 0x3a, 0xc5, 0x41, 0x87, 0xfa, 0x48, 0x18, 0xd1, 0x7f,
];
/// The password Chromium's `PosixKeyProvider` uses when no secret service is
/// available (`--password-store=basic`). Records sealed with it carry `v10`.
// Read on Linux and by the known-answer tests; unreferenced on other hosts.
#[allow(dead_code)]
pub const POSIX_FALLBACK_PASSWORD: &[u8] = b"peanuts";
/// PBKDF2 iteration counts, per the provider that owns each platform.
// Each host only ever derives with its own count, but both are needed to read
// a profile produced on the other one.
#[allow(dead_code)]
pub const MAC_ITERATIONS: u32 = 1003;
#[allow(dead_code)]
pub const POSIX_ITERATIONS: u32 = 1;
/// Derive a 16-byte AES-128 key the way every CBC os_crypt provider does.
///
/// `password` is the raw bytes, never trimmed: Chromium passes the exact
/// `ReadFileToString` result to the KDF, so normalising here would silently
/// produce a different key and every decrypt would fail.
// Called from the mac and Linux branches only: `DPAPIKeyProvider` takes the
// 32 bytes on disk as the AES-256 key with no derivation step at all.
#[allow(dead_code)]
pub fn derive_key(password: &[u8], iterations: u32) -> [u8; 16] {
let mut key = [0u8; 16];
// ring rather than the `pbkdf2` crate: sha1 0.11 (digest 0.11) and
// pbkdf2 0.12 (digest 0.10) cannot coexist. ring is self-contained.
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA1,
NonZeroU32::new(iterations).expect("iterations must be non-zero"),
SALT,
password,
&mut key,
);
key
}
/// One os_crypt cipher, keyed. Which variant applies is decided by the tag the
/// record carries, never by the host platform.
#[derive(Clone)]
pub enum CryptoKey {
Aes128Cbc([u8; 16]),
// Only Windows keys with GCM, but the variant has to exist everywhere so the
// tag dispatch in `SourceKeyring` stays platform-independent.
#[allow(dead_code)]
Aes256Gcm([u8; 32]),
}
impl CryptoKey {
/// Decrypt a *tagless* ciphertext (the caller has already stripped the
/// 3-byte version prefix).
pub fn decrypt(&self, ciphertext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
if ciphertext.is_empty() {
return Some(Vec::new());
}
let mut buf = ciphertext.to_vec();
Aes128CbcDec::new(key.into(), &CBC_IV.into())
.decrypt_padded::<Pkcs7>(&mut buf)
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
if ciphertext.len() < GCM_NONCE_LEN {
return None;
}
let (nonce, body) = ciphertext.split_at(GCM_NONCE_LEN);
let nonce: [u8; GCM_NONCE_LEN] = nonce.try_into().ok()?;
Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.decrypt(
&Nonce::from(nonce),
Payload {
msg: body,
aad: &[],
},
)
.ok()
}
}
}
/// Encrypt to a *tagless* ciphertext. The caller prepends the tag.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
let mut buf = vec![0u8; plaintext.len() + 16];
buf[..plaintext.len()].copy_from_slice(plaintext);
Aes128CbcEnc::new(key.into(), &CBC_IV.into())
.encrypt_padded::<Pkcs7>(&mut buf, plaintext.len())
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
let nonce: [u8; GCM_NONCE_LEN] = rand::rng().random();
let sealed = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.encrypt(
&Nonce::from(nonce),
Payload {
msg: plaintext,
aad: &[],
},
)
.ok()?;
// The nonce goes at the front, matching `Encryptor::Key::Encrypt`.
let mut out = Vec::with_capacity(GCM_NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Some(out)
}
}
}
}
/// Wayfern's key for the profile being created.
pub struct TargetKey {
key: CryptoKey,
tag: &'static [u8; 3],
}
impl TargetKey {
/// The tag the host platform's key provider claims.
pub const fn host_tag() -> &'static [u8; 3] {
#[cfg(target_os = "linux")]
{
b"v11"
}
#[cfg(not(target_os = "linux"))]
{
b"v10"
}
}
/// Build the key from the raw `os_crypt_key` file contents.
///
/// Returns `None` when the contents cannot key the host cipher — on Windows
/// that means anything other than exactly 32 bytes, which is what
/// `DPAPIKeyProvider` requires before it will adopt a portable key.
fn from_file_contents(contents: &[u8]) -> Option<Self> {
if contents.is_empty() {
return None;
}
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().ok()?;
Some(Self {
key: CryptoKey::Aes256Gcm(bytes),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "macos")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "linux")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)),
tag: Self::host_tag(),
})
}
}
/// Fresh key material in the host platform's `os_crypt_key` format.
fn generate_file_contents() -> Vec<u8> {
#[cfg(target_os = "windows")]
{
// Windows stores the AES-256 key itself, so it must be 32 bytes.
let key: [u8; 32] = rand::rng().random();
key.to_vec()
}
#[cfg(not(target_os = "windows"))]
{
// mac/Linux store a *password* that is fed to PBKDF2. Wayfern mints
// `base64(16 random bytes)`; match it so the file is indistinguishable
// from one the browser wrote itself.
let raw: [u8; 16] = rand::rng().random();
base64::engine::general_purpose::STANDARD
.encode(raw)
.into_bytes()
}
}
/// Read the existing `os_crypt_key`, or mint and persist one.
///
/// Writing eagerly at import time — rather than letting the first launch do
/// it — is deliberate. The mac and Linux patches have no `else` branch when
/// the write fails, so the browser would run on an in-memory key that dies
/// with the process and orphans everything it wrote. Failing here instead
/// turns that silent data loss into a visible import error.
pub fn ensure(user_data_dir: &Path) -> Result<Self, String> {
let key_file = user_data_dir.join(KEY_FILE_NAME);
if let Ok(existing) = std::fs::read(&key_file) {
if let Some(key) = Self::from_file_contents(&existing) {
return Ok(key);
}
// Present but unusable (a Windows-format key on macOS, say, or a
// truncated write). Replacing it is safe only because import always
// re-encrypts into whatever key we end up with.
log::warn!(
"Replacing unusable {KEY_FILE_NAME} ({} bytes) at {}",
existing.len(),
key_file.display()
);
}
std::fs::create_dir_all(user_data_dir)
.map_err(|e| format!("Failed to create profile directory: {e}"))?;
let contents = Self::generate_file_contents();
std::fs::write(&key_file, &contents)
.map_err(|e| format!("Failed to write os_crypt_key: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600));
}
// Read back rather than trust the write: a key that did not land is the
// one failure mode that silently destroys every secret we are about to
// write with it.
let written =
std::fs::read(&key_file).map_err(|e| format!("Failed to verify os_crypt_key: {e}"))?;
if written != contents {
return Err("os_crypt_key verification failed after write".to_string());
}
Self::from_file_contents(&contents).ok_or_else(|| "Failed to derive os_crypt_key".to_string())
}
/// Seal a value the way Wayfern will expect to find it: `tag || ciphertext`.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
let body = self.key.encrypt(plaintext)?;
let mut out = Vec::with_capacity(3 + body.len());
out.extend_from_slice(self.tag);
out.extend_from_slice(&body);
Some(out)
}
}
/// What a decrypt attempt produced.
pub enum Decrypted {
/// Recovered plaintext.
Value(Vec<u8>),
/// Already plaintext — no recognised version tag.
NotEncrypted,
/// Correctly identified but not openable: no key for the tag (Windows
/// App-Bound `v20`), or every candidate key failed.
Unrecoverable,
}
/// The source browser's keys, indexed by the tag the records carry.
///
/// Indexing by tag rather than by platform is not pedantry: a single Linux
/// profile can legitimately hold both `v10` (peanuts) and `v11` (keyring)
/// records, because the available secret service changes between sessions.
#[derive(Default)]
pub struct SourceKeyring {
pub v10: Option<CryptoKey>,
pub v11: Option<CryptoKey>,
/// Seen at least one `v20` (Windows App-Bound) record, which no third party
/// can open. Tracked so the import report can say so explicitly.
pub saw_app_bound: std::cell::Cell<bool>,
}
impl SourceKeyring {
pub fn is_empty(&self) -> bool {
self.v10.is_none() && self.v11.is_none()
}
/// Open one stored value, dispatching on its version tag exactly as
/// `Encryptor::DecryptData` does.
pub fn decrypt(&self, stored: &[u8]) -> Decrypted {
if stored.len() < 3 {
return if stored.is_empty() {
Decrypted::Value(Vec::new())
} else {
Decrypted::NotEncrypted
};
}
let (tag, body) = stored.split_at(3);
let key = match tag {
b"v10" => self.v10.as_ref(),
b"v11" => self.v11.as_ref(),
b"v20" => {
// App-Bound Encryption. The key is wrapped by the SYSTEM-level Chrome
// Elevation Service, which validates the calling binary. There is no
// legitimate way for us to unwrap it.
self.saw_app_bound.set(true);
return Decrypted::Unrecoverable;
}
_ => return Decrypted::NotEncrypted,
};
let Some(key) = key else {
return Decrypted::Unrecoverable;
};
if let Some(plaintext) = key.decrypt(body) {
return Decrypted::Value(plaintext);
}
// Chromium's own fallback for CBC records sealed with an empty password.
if matches!(key, CryptoKey::Aes128Cbc(_)) {
if let Some(plaintext) = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY).decrypt(body) {
return Decrypted::Value(plaintext);
}
}
Decrypted::Unrecoverable
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn empty_password_key_matches_chromium_constant() {
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
}
#[test]
fn peanuts_key_matches_known_vector() {
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
// breaks every Linux `--password-store=basic` import.
assert_eq!(
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
[
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
0x78
]
);
}
#[test]
fn cbc_round_trip() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"session-token").expect("encrypt");
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
}
#[test]
fn cbc_round_trip_empty_plaintext() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"").expect("encrypt");
// PKCS7 always emits a full padding block, so this must not be empty.
assert_eq!(sealed.len(), 16);
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
}
#[test]
fn gcm_round_trip_with_fresh_nonce_each_time() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let a = key.encrypt(b"session-token").expect("encrypt");
let b = key.encrypt(b"session-token").expect("encrypt");
assert_ne!(a, b, "nonce must be random per call");
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
}
#[test]
fn gcm_rejects_tampered_ciphertext() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
let last = sealed.len() - 1;
sealed[last] ^= 0xff;
assert!(key.decrypt(&sealed).is_none());
}
#[test]
fn target_key_is_stable_across_calls() {
let dir = TempDir::new().unwrap();
let first = TargetKey::ensure(dir.path()).expect("mint");
let sealed = first.encrypt(b"value").expect("encrypt");
let second = TargetKey::ensure(dir.path()).expect("reuse");
// Re-running import over the same directory must not orphan what the
// previous run wrote.
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
assert_eq!(
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
second.encrypt(b"probe").map(|v| v[..3].to_vec())
);
let mut keyring = SourceKeyring::default();
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
install_host_key(&mut keyring, &contents);
match keyring.decrypt(&sealed) {
Decrypted::Value(v) => assert_eq!(v, b"value"),
_ => panic!("target key must round-trip through the source keyring"),
}
}
#[test]
fn minted_key_matches_wayfern_file_format() {
let dir = TempDir::new().unwrap();
TargetKey::ensure(dir.path()).expect("mint");
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
#[cfg(target_os = "windows")]
assert_eq!(
contents.len(),
32,
"DPAPIKeyProvider only adopts a 32-byte portable key"
);
#[cfg(not(target_os = "windows"))]
{
// Wayfern writes base64(16 random bytes) = 24 ASCII chars.
assert_eq!(contents.len(), 24);
let text = String::from_utf8(contents).expect("ascii");
assert!(
base64::engine::general_purpose::STANDARD
.decode(&text)
.map(|b| b.len())
== Ok(16),
"expected base64 of 16 bytes, got {text}"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
let keyring = SourceKeyring::default();
assert!(matches!(
keyring.decrypt(b"plain cookie value"),
Decrypted::NotEncrypted
));
}
#[test]
fn app_bound_records_are_flagged_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v20".to_vec();
sealed.extend_from_slice(&[0u8; 40]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
assert!(
keyring.saw_app_bound.get(),
"v20 must be reported to the user, not silently dropped"
);
}
#[test]
fn missing_key_for_known_tag_is_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v10".to_vec();
sealed.extend_from_slice(&[0u8; 32]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
}
#[test]
fn empty_password_fallback_recovers_the_record() {
// A record sealed with the empty-password key must still open when the
// keyring holds a different primary key, mirroring Chromium.
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
.encrypt(b"legacy")
.unwrap();
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&sealed_body);
let keyring = SourceKeyring {
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
..Default::default()
};
match keyring.decrypt(&stored) {
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
_ => panic!("empty-password fallback must be attempted"),
}
}
/// Load the host-format key into a keyring under the host tag, for tests
/// that need to verify what we wrote is what Wayfern will read.
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().unwrap();
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
}
#[cfg(target_os = "macos")]
{
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
}
#[cfg(target_os = "linux")]
{
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
}
}
}
+109
View File
@@ -0,0 +1,109 @@
//! What an import actually carried across.
//!
//! Import is best-effort by nature: a locked keychain, a Windows App-Bound
//! cookie store or a schema too old for Chromium to migrate all mean some
//! subset does not survive, and none of them should abort the whole operation.
//! The report is how that stays honest — every skipped store is a counted
//! warning rather than a silent zero.
use serde::{Deserialize, Serialize};
/// Stable warning codes. The frontend maps these to
/// `importProfile.warnings.*`, so they are part of the API contract: rename one
/// and the user sees a missing translation.
pub mod warning {
/// The source browser's key could not be read, so cookies/passwords were
/// left encrypted and are unreadable in the new profile.
pub const SECRETS_NOT_MIGRATED: &str = "secretsNotMigrated";
/// Windows App-Bound Encryption (Chrome 127+). Unrecoverable by design.
pub const APP_BOUND_ENCRYPTED: &str = "appBoundEncrypted";
/// A store's schema predates what Chromium will migrate; it would have been
/// deleted on first launch, so it was skipped instead.
pub const STORE_TOO_OLD: &str = "storeTooOld";
/// A store's schema is newer than this Chromium can read.
pub const STORE_TOO_NEW: &str = "storeTooNew";
/// The source browser was running; databases were snapshotted but LevelDB
/// site data may be incomplete.
pub const SOURCE_BROWSER_RUNNING: &str = "sourceBrowserRunning";
/// Tracked preferences lost their MACs and will reset to defaults.
pub const SECURE_PREFERENCES_RESET: &str = "securePreferencesReset";
/// At least one extension could not be carried.
pub const EXTENSIONS_PARTIAL: &str = "extensionsPartial";
/// A database was unreadable and was skipped rather than copied corrupt.
pub const STORE_UNREADABLE: &str = "storeUnreadable";
}
/// Per-profile outcome, returned alongside each item in a batch import.
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProfileImportReport {
/// Cookies whose value is readable in the new profile.
pub cookies_migrated: usize,
/// Cookies carried over as rows but whose value could not be recovered.
pub cookies_unrecoverable: usize,
pub passwords_migrated: usize,
pub passwords_unrecoverable: usize,
/// Saved cards / IBANs / autofill secrets re-encrypted.
pub payment_methods_migrated: usize,
pub payment_methods_unrecoverable: usize,
pub extensions_migrated: usize,
pub history_entries: usize,
pub bookmarks: usize,
/// Origins with Local Storage data.
pub local_storage_origins: usize,
pub bytes_copied: u64,
/// Stable codes from [`warning`], deduplicated, in insertion order.
pub warnings: Vec<String>,
}
impl ProfileImportReport {
pub fn warn(&mut self, code: &str) {
if !self.warnings.iter().any(|w| w == code) {
self.warnings.push(code.to_string());
}
}
/// True when nothing readable came across. Used to decide whether the UI
/// should present the import as a success or as a warning.
pub fn is_empty_import(&self) -> bool {
self.cookies_migrated == 0
&& self.passwords_migrated == 0
&& self.history_entries == 0
&& self.bookmarks == 0
&& self.local_storage_origins == 0
&& self.extensions_migrated == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warnings_are_deduplicated_in_order() {
let mut report = ProfileImportReport::default();
report.warn(warning::STORE_TOO_OLD);
report.warn(warning::SECRETS_NOT_MIGRATED);
report.warn(warning::STORE_TOO_OLD);
assert_eq!(
report.warnings,
vec![
warning::STORE_TOO_OLD.to_string(),
warning::SECRETS_NOT_MIGRATED.to_string()
]
);
}
#[test]
fn empty_import_detection_ignores_unrecoverable_counts() {
let mut report = ProfileImportReport {
cookies_unrecoverable: 500,
..Default::default()
};
assert!(
report.is_empty_import(),
"500 unreadable cookies is still nothing carried"
);
report.history_entries = 1;
assert!(!report.is_empty_import());
}
}
File diff suppressed because it is too large Load Diff
+74 -26
View File
@@ -9,6 +9,7 @@ use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::profile_import::report::ProfileImportReport;
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
@@ -28,6 +29,9 @@ pub struct DetectedProfile {
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct ImportProfileItem {
pub source_path: String,
/// The source browser family (`chromium`, `brave`, `edge`, …). Load-bearing:
/// it selects which Keychain / secret-service item holds the key that
/// unlocks the source's cookies and passwords.
#[serde(default = "default_import_browser_type")]
pub browser_type: String,
pub new_profile_name: String,
@@ -35,6 +39,10 @@ pub struct ImportProfileItem {
pub proxy_id: Option<String>,
#[serde(default)]
pub vpn_id: Option<String>,
/// Import even though the source browser is running. Databases are still
/// snapshotted consistently, but LevelDB site data may be mid-write.
#[serde(default)]
pub allow_running: Option<bool>,
}
fn default_import_browser_type() -> String {
@@ -61,6 +69,8 @@ pub struct ProfileImportItemResult {
pub profile_id: Option<String>,
/// Structured `{"code": …}` error string when status is "failed".
pub error: Option<String>,
/// What actually came across. Present when status is "imported".
pub report: Option<ProfileImportReport>,
}
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
@@ -719,6 +729,7 @@ impl ProfileImporter {
status: "failed".to_string(),
profile_id: None,
error: Some(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()),
report: None,
});
continue;
}
@@ -735,6 +746,7 @@ impl ProfileImporter {
status: "skipped".to_string(),
profile_id: None,
error: None,
report: None,
});
continue;
}
@@ -757,10 +769,11 @@ impl ProfileImporter {
item.vpn_id.clone(),
group_id.clone(),
wayfern_config.clone(),
item.allow_running.unwrap_or(false),
)
.await
{
Ok(profile) => {
Ok((profile, report)) => {
imported_count += 1;
completed += 1;
emit_import_progress(total, completed, index, &final_name, "imported");
@@ -771,6 +784,7 @@ impl ProfileImporter {
status: "imported".to_string(),
profile_id: Some(profile.id.to_string()),
error: None,
report: Some(report),
});
}
Err(e) => {
@@ -785,6 +799,7 @@ impl ProfileImporter {
status: "failed".to_string(),
profile_id: None,
error: Some(error_to_code_string(e)),
report: None,
});
}
}
@@ -809,7 +824,8 @@ impl ProfileImporter {
vpn_id: Option<String>,
group_id: Option<String>,
wayfern_config: Option<WayfernConfig>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
allow_running: bool,
) -> Result<(BrowserProfile, ProfileImportReport), Box<dyn std::error::Error>> {
let source_path = Path::new(source_path);
if !source_path.exists() {
return Err(
@@ -847,39 +863,54 @@ impl ProfileImporter {
create_dir_all(&new_profile_uuid_dir)?;
create_dir_all(&new_profile_data_dir)?;
// Profile dirs can be multiple GB — keep the copy off the async runtime.
let copy_source = source_path.to_path_buf();
let copy_dest = new_profile_data_dir.clone();
let copy_result = match tokio::task::spawn_blocking(move || {
Self::copy_directory_recursive(&copy_source, &copy_dest).map_err(|e| e.to_string())
// Profile dirs can be multiple GB and the migration hits SQLite and the
// OS keyring — keep all of it off the async runtime.
let migrate_source = source_path.to_path_buf();
let migrate_dest = new_profile_data_dir.clone();
let source_family = browser_type.to_string();
let migrate_result = match tokio::task::spawn_blocking(move || {
crate::profile_import::import_into(
&migrate_source,
&migrate_dest,
&source_family,
allow_running,
)
})
.await
{
Ok(r) => r,
Err(e) => {
// The copy task died (panic, or runtime shutdown mid-import). Clean up
// like every other error path here, or the half-copied — possibly
// multi-GB — directory is orphaned with no metadata pointing at it, so
// nothing ever reclaims it.
// The task died (panic, or runtime shutdown mid-import). Clean up like
// every other error path here, or the half-copied — possibly multi-GB
// — directory is orphaned with no metadata pointing at it, so nothing
// ever reclaims it.
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
return Err(
serde_json::json!({
"code": "INTERNAL_ERROR",
"params": { "detail": format!("Profile copy task failed: {e}") },
"params": { "detail": format!("Profile import task failed: {e}") },
})
.to_string()
.into(),
);
}
};
if let Err(e) = copy_result {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
return Err(
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } })
.to_string()
.into(),
);
}
let report = match migrate_result {
Ok(report) => report,
Err(e) => {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
// Structured codes (an unimportable source, a running browser) pass
// through so the frontend can translate them; anything else is
// internal.
return Err(if e.starts_with('{') {
e.into()
} else {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } })
.to_string()
.into()
});
}
};
let version = match self.get_default_version_for_browser(mapped) {
Ok(version) => version,
@@ -1017,13 +1048,30 @@ impl ProfileImporter {
self.profile_manager.save_profile(&profile)?;
log::info!(
"Successfully imported profile '{}' from '{}'",
new_profile_name,
source_path.display()
);
if report.is_empty_import() {
// Not an error — an empty source profile imports legitimately — but it is
// the exact symptom the old layout bug produced, so it is worth a loud
// line in the log rather than a silent success.
log::warn!(
"Imported profile '{}' from '{}' carried no readable data (warnings: {:?})",
new_profile_name,
source_path.display(),
report.warnings
);
} else {
log::info!(
"Imported profile '{}' from '{}': {} cookies, {} passwords, {} history entries ({} unrecoverable secrets, warnings: {:?})",
new_profile_name,
source_path.display(),
report.cookies_migrated,
report.passwords_migrated,
report.history_entries,
report.cookies_unrecoverable + report.passwords_unrecoverable,
report.warnings
);
}
Ok(profile)
Ok((profile, report))
}
fn get_default_version_for_browser(
+18 -3
View File
@@ -464,10 +464,10 @@ impl ProxyManager {
.as_deref()
.filter(|uri| !uri.is_empty())
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
let parsed = crate::xray::parse_vless_uri(uri)
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
let parsed =
crate::xray::parse_vless_uri(uri).map_err(|error| crate::vless_config_error(&error))?;
let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
.map_err(|error| crate::vless_config_error(&error))?;
proxy_settings.proxy_type = "vless".to_string();
proxy_settings.host = parsed.config.address;
@@ -1152,6 +1152,21 @@ impl ProxyManager {
url
}
/// Proxy URL for a diagnostic probe made by the app itself (reqwest), as
/// opposed to `build_proxy_url`, which feeds the browser.
///
/// SOCKS5 becomes `socks5h://` so the probe endpoint's hostname resolves at
/// the exit rather than on this machine. Resolving locally would leak the
/// real DNS and, behind a split-horizon resolver, can reach a different host
/// than the browser would.
pub fn build_probe_proxy_url(proxy_settings: &ProxySettings) -> String {
let url = Self::build_proxy_url(proxy_settings);
if proxy_settings.proxy_type.eq_ignore_ascii_case("socks5") {
return url.replacen("socks5://", "socks5h://", 1);
}
url
}
// Check if a proxy is valid by routing through a temporary donut-proxy process.
// This tests the exact same code path the browser uses.
// Falls back to direct reqwest check if the proxy worker fails to start.
+511
View File
@@ -0,0 +1,511 @@
//! Whether a profile's exit node can be reached from somewhere that is not this
//! machine.
//!
//! Remote execution — an interactive remote session or a Cookie Bot night — runs
//! the browser on a leased fleet host, but the PROFILE (and its proxy, and its
//! VPN config) is pulled from the user's sync namespace. Nothing in that
//! handover rewrites addresses, so a proxy recorded as `127.0.0.1:8080` arrives
//! on the fleet host meaning *the fleet host's own loopback*.
//!
//! That is the whole bug this module exists to prevent. The server already
//! refuses a profile with NO exit (`proxy_required`), because a night browsed
//! from the fleet's datacenter address damages an identity rather than building
//! it — but it was asking whether an exit was *configured*, never whether it was
//! *reachable*. A local proxy satisfied the first question and failed the
//! second, so the run was accepted, dispatched, and burned a leased host either
//! erroring out or (worse) egressing direct from the datacenter: exactly the
//! outcome `proxy_required` exists to stop, reached by the one route it did not
//! check.
//!
//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a
//! locally-run SOCKS client and Donut's own VLESS support all present to the
//! browser as `127.0.0.1:<port>`.
//!
//! This module is the single answer, shared by every caller, and it FAILS
//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a
//! working setup costs the user one support question; accepting a broken one
//! costs a burned hour and a damaged profile identity.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// Whether a leased fleet host could dial this profile's exit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExitReachability {
/// No proxy and no VPN. The caller's existing "no exit" refusal applies.
None,
/// An address a host elsewhere on the internet can reach.
Remote,
/// An address that only means anything on this machine or this LAN.
LocalOnly {
/// The offending host, for a message the user can act on.
host: String,
/// Which part of the config it came from: "proxy" or "VPN".
source: &'static str,
},
/// Configured, but this code could not determine the host.
///
/// Treated as unreachable by [`ExitReachability::is_remote`] — see the
/// fail-closed note in the module docs.
Unknown {
reason: String,
source: &'static str,
},
}
impl ExitReachability {
/// Whether remote execution may proceed.
pub fn is_remote(&self) -> bool {
matches!(self, ExitReachability::Remote)
}
/// A one-line reason for a refusal, or None when there is nothing to refuse.
pub fn refusal_detail(&self) -> Option<String> {
match self {
ExitReachability::Remote | ExitReachability::None => None,
ExitReachability::LocalOnly { host, source } => Some(format!(
"The {source} for this profile points at {host}, which only exists on this computer. \
Remote runs happen on our hosts and cannot reach it."
)),
ExitReachability::Unknown { reason, source } => Some(format!(
"The {source} for this profile could not be read ({reason}), so we cannot confirm a \
remote host could use it."
)),
}
}
}
/// Whether a hostname or IP literal is reachable from another machine.
///
/// Rejects, in order: empty/whitespace, unparsable-as-either, and every IP
/// range that is scoped to a machine or a private network. Hostnames that are
/// not IP literals are accepted unless they use a name suffix that is
/// definitionally local — a public DNS name cannot be validated here without a
/// lookup, and doing a lookup would make this impure and slow on a hot path.
pub fn host_is_remote_reachable(host: &str) -> bool {
let host = normalize_host(host);
if host.is_empty() {
return false;
}
if let Ok(ip) = host.parse::<IpAddr>() {
return ip_is_remote_reachable(ip);
}
let lower = host.to_ascii_lowercase();
// `localhost` and anything under it resolve to loopback everywhere.
if lower == "localhost" || lower.ends_with(".localhost") {
return false;
}
// Suffixes reserved for local/private name resolution (RFC 6762 mDNS, RFC
// 8375, and the names router vendors hand out on a LAN). A fleet host
// resolving one of these gets its own network's answer, not the user's.
const LOCAL_SUFFIXES: [&str; 7] = [
".local",
".localdomain",
".internal",
".home",
".home.arpa",
".lan",
".intranet",
];
if LOCAL_SUFFIXES.iter().any(|suffix| lower.ends_with(suffix)) {
return false;
}
// A bare single-label name ("my-proxy", "router") is only resolvable through
// a local search domain, so it is no more use to a fleet host than `.local`.
if !lower.contains('.') {
return false;
}
true
}
/// Whether an IP literal is routable from another machine.
fn ip_is_remote_reachable(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => ipv4_is_remote_reachable(v4),
IpAddr::V6(v6) => ipv6_is_remote_reachable(v6),
}
}
fn ipv4_is_remote_reachable(ip: Ipv4Addr) -> bool {
// `is_private`/`is_loopback`/`is_link_local` are stable; the rest are not, so
// the remaining ranges are spelled out rather than gated behind a nightly
// feature.
if ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() {
return false;
}
if ip.is_broadcast() || ip.is_multicast() || ip.is_documentation() {
return false;
}
let [a, b, ..] = ip.octets();
// 100.64.0.0/10 — carrier-grade NAT (RFC 6598). Reachable inside one
// carrier's network and nowhere else.
if a == 100 && (64..128).contains(&b) {
return false;
}
// 0.0.0.0/8 "this network", and 240.0.0.0/4 reserved.
if a == 0 || a >= 240 {
return false;
}
true
}
fn ipv6_is_remote_reachable(ip: Ipv6Addr) -> bool {
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
return false;
}
// An IPv4 address wearing an IPv6 hat is still that IPv4 address — classify
// it as one, or `::ffff:127.0.0.1` walks straight through.
if let Some(v4) = ip.to_ipv4_mapped() {
return ipv4_is_remote_reachable(v4);
}
if let Some(v4) = ip.to_ipv4() {
return ipv4_is_remote_reachable(v4);
}
let segments = ip.segments();
// fc00::/7 unique-local, fe80::/10 link-local.
if (segments[0] & 0xfe00) == 0xfc00 {
return false;
}
if (segments[0] & 0xffc0) == 0xfe80 {
return false;
}
true
}
/// Strip the decoration a host can arrive wrapped in: whitespace, `[...]`
/// around an IPv6 literal, a trailing dot on an FQDN, and any `user@` or
/// `:port` that came along from a URI.
fn normalize_host(raw: &str) -> String {
let mut host = raw.trim();
if host.is_empty() {
return String::new();
}
// `user:pass@host` — take what follows the LAST '@', since a password may
// itself contain one.
if let Some(at) = host.rfind('@') {
host = &host[at + 1..];
}
// Bracketed IPv6, optionally with a port: `[::1]:1080`.
if let Some(stripped) = host.strip_prefix('[') {
if let Some(end) = stripped.find(']') {
return stripped[..end].trim().to_string();
}
return stripped.trim().to_string();
}
// `host:port`, but only when there is exactly one colon — more than one means
// a bare IPv6 literal, whose colons are part of the address.
if host.matches(':').count() == 1 {
if let Some((left, _port)) = host.split_once(':') {
host = left;
}
}
host.trim().trim_end_matches('.').to_string()
}
/// The host a VLESS URI actually dials.
///
/// Load-bearing because of an asymmetry that is easy to get backwards: a VLESS
/// proxy presents to the browser as `127.0.0.1:<port>` — Donut runs a local xray
/// worker and points the browser at it — but the address that decides whether
/// anyone else could use this config is the SERVER inside the URI. The local
/// port is an implementation detail of this machine; the URI is the exit.
pub fn vless_uri_host(uri: &str) -> Option<String> {
let rest = uri.trim().strip_prefix("vless://")?;
// Cut the fragment (`#label`) and query (`?type=...`) before looking for the
// authority — either may contain '@' or ':'.
let rest = rest.split('#').next()?;
let rest = rest.split('?').next()?;
// `uuid@host:port/...`
let authority = rest.split('/').next()?;
let host_port = authority
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(authority);
let host = normalize_host(host_port);
if host.is_empty() {
None
} else {
Some(host)
}
}
/// The exit host a stored proxy represents, as a remote host would have to dial
/// it.
pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result<String, String> {
if settings.proxy_type.eq_ignore_ascii_case("vless") {
let uri = settings
.vless_uri
.as_deref()
.filter(|uri| !uri.trim().is_empty())
.ok_or_else(|| "VLESS proxy has no server URI".to_string())?;
return vless_uri_host(uri).ok_or_else(|| "VLESS server URI is malformed".to_string());
}
let host = normalize_host(&settings.host);
if host.is_empty() {
return Err("proxy has no host".to_string());
}
Ok(host)
}
/// Classify a stored proxy.
pub fn classify_proxy(settings: &crate::browser::ProxySettings) -> ExitReachability {
match proxy_exit_host(settings) {
Err(reason) => ExitReachability::Unknown {
reason,
source: "proxy",
},
Ok(host) => {
if host_is_remote_reachable(&host) {
ExitReachability::Remote
} else {
ExitReachability::LocalOnly {
host,
source: "proxy",
}
}
}
}
}
/// Classify a WireGuard peer endpoint (`host:port`).
pub fn classify_wireguard_endpoint(peer_endpoint: &str) -> ExitReachability {
let host = normalize_host(peer_endpoint);
if host.is_empty() {
return ExitReachability::Unknown {
reason: "VPN config has no peer endpoint".to_string(),
source: "VPN",
};
}
if host_is_remote_reachable(&host) {
ExitReachability::Remote
} else {
ExitReachability::LocalOnly {
host,
source: "VPN",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::ProxySettings;
fn proxy(proxy_type: &str, host: &str) -> ProxySettings {
ProxySettings {
proxy_type: proxy_type.to_string(),
host: host.to_string(),
port: 8080,
username: None,
password: None,
vless_uri: None,
}
}
#[test]
fn loopback_in_every_spelling_is_local() {
// The literal case the bug was reported for, plus the spellings that reach
// the same place. `::ffff:127.0.0.1` is the one a naive IPv6 check misses.
for host in [
"127.0.0.1",
"127.1.2.3",
"localhost",
"LOCALHOST",
"foo.localhost",
"::1",
"[::1]",
"::ffff:127.0.0.1",
"0.0.0.0",
"::",
] {
assert!(
!host_is_remote_reachable(host),
"{host} should not be remote-reachable"
);
}
}
#[test]
fn private_and_carrier_ranges_are_local() {
for host in [
"10.0.0.1",
"192.168.1.1",
"172.16.0.1",
"172.31.255.254",
"169.254.1.1", // link-local / APIPA
"100.64.0.1", // CGNAT
"100.127.255.1",
"fd00::1", // unique-local
"fe80::1", // link-local
"240.0.0.1",
"0.1.2.3",
] {
assert!(
!host_is_remote_reachable(host),
"{host} should not be remote-reachable"
);
}
}
#[test]
fn public_addresses_and_names_are_reachable() {
for host in [
"1.1.1.1",
"8.8.8.8",
"172.15.0.1", // just outside 172.16/12
"172.32.0.1",
"100.63.255.255", // just outside 100.64/10
"100.128.0.1",
"2606:4700:4700::1111",
"proxy.example.com",
"gate.smartproxy.net.",
"residential.example.co.uk",
] {
assert!(
host_is_remote_reachable(host),
"{host} should be remote-reachable"
);
}
}
#[test]
fn lan_only_names_are_local() {
// A fleet host resolving these gets ITS network's answer, not the user's —
// which is worse than failing, because it may well succeed against
// something unrelated.
for host in [
"my-proxy", // single label: needs a search domain
"router.local",
"nas.home.arpa",
"proxy.lan",
"box.internal",
"server.localdomain",
"gateway.intranet",
] {
assert!(
!host_is_remote_reachable(host),
"{host} should not be remote-reachable"
);
}
}
#[test]
fn host_port_and_credentials_are_stripped_before_classifying() {
assert!(!host_is_remote_reachable("127.0.0.1:8080"));
assert!(!host_is_remote_reachable("user:pass@127.0.0.1:8080"));
assert!(!host_is_remote_reachable("[::1]:1080"));
assert!(host_is_remote_reachable("user:p@ss@proxy.example.com:8080"));
}
#[test]
fn a_vless_proxy_is_judged_by_its_server_not_its_local_port() {
// THE asymmetry. Donut points the browser at a local xray worker, so the
// browser-facing address of every VLESS proxy is 127.0.0.1 — but the stored
// config names a real server, and that is what a fleet host would dial.
// Classifying VLESS off `settings.host` would refuse every VLESS profile.
let mut settings = proxy("vless", "127.0.0.1");
settings.vless_uri =
Some("vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?type=tcp#node".into());
assert_eq!(classify_proxy(&settings), ExitReachability::Remote);
}
#[test]
fn a_vless_uri_pointing_at_loopback_is_still_local() {
let mut settings = proxy("vless", "127.0.0.1");
settings.vless_uri = Some("vless://uuid@127.0.0.1:443?type=tcp".into());
assert_eq!(
classify_proxy(&settings),
ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
source: "proxy",
}
);
}
#[test]
fn vless_host_parsing_survives_query_and_fragment() {
assert_eq!(
vless_uri_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label"),
Some("example.com".to_string())
);
assert_eq!(
vless_uri_host("vless://uuid@[2606:4700::1111]:443?type=ws"),
Some("2606:4700::1111".to_string())
);
assert_eq!(vless_uri_host("not-a-vless-uri"), None);
}
#[test]
fn an_unreadable_config_fails_closed() {
// Unknown must never be treated as usable: the point of the check is that
// we could not confirm reachability, and guessing "yes" reintroduces the
// exact failure it prevents.
let mut settings = proxy("vless", "");
settings.vless_uri = None;
let verdict = classify_proxy(&settings);
assert!(matches!(verdict, ExitReachability::Unknown { .. }));
assert!(!verdict.is_remote());
assert!(verdict.refusal_detail().is_some());
}
#[test]
fn ordinary_proxies_are_classified_by_host() {
assert_eq!(
classify_proxy(&proxy("socks5", "gate.example.com")),
ExitReachability::Remote
);
assert_eq!(
classify_proxy(&proxy("http", "192.168.0.10")),
ExitReachability::LocalOnly {
host: "192.168.0.10".to_string(),
source: "proxy",
}
);
}
#[test]
fn wireguard_endpoints_are_classified_by_their_peer() {
assert_eq!(
classify_wireguard_endpoint("vpn.example.com:51820"),
ExitReachability::Remote
);
assert_eq!(
classify_wireguard_endpoint("10.0.0.1:51820"),
ExitReachability::LocalOnly {
host: "10.0.0.1".to_string(),
source: "VPN",
}
);
assert!(matches!(
classify_wireguard_endpoint(" "),
ExitReachability::Unknown { .. }
));
}
#[test]
fn only_remote_permits_a_run() {
assert!(ExitReachability::Remote.is_remote());
assert!(!ExitReachability::None.is_remote());
assert!(!ExitReachability::LocalOnly {
host: "127.0.0.1".into(),
source: "proxy"
}
.is_remote());
// `None` has no detail: the caller's existing "no exit at all" refusal is
// the better message, and two refusals for one condition read as a bug.
assert!(ExitReachability::None.refusal_detail().is_none());
}
}
+561
View File
@@ -0,0 +1,561 @@
//! What a remote session owes this machine, and the gate that collects it.
//!
//! A profile that runs on the leased fleet is written by the host, not here.
//! The host pushes it back to cloud storage when the session ends, and until
//! this machine has pulled that push, the local profile directory is a stale
//! copy of something that has moved on.
//!
//! Opening that stale copy is not a cosmetic problem, it is destructive. The
//! local browser writes, every local mtime jumps past the host's push, and the
//! next ordinary sync therefore reads local as the newer side: it uploads the
//! pre-session files and puts everything the host wrote into
//! `files_to_delete_remote`. A night of cookie warming is deleted with no error
//! anywhere. Nothing in the manifest can prevent this, because by then the local
//! clock genuinely IS later.
//!
//! So the gate is here instead, and it is deliberately a LOCAL, per-machine
//! fact rather than a synced one. "This computer has not yet pulled" is true of
//! one computer at a time; putting it in the profile's synced metadata would let
//! a second device that had already pulled clear it for a first device that had
//! not.
//!
//! Two states, and the difference matters to the user:
//!
//! - [`HandoffState::Running`]: a session is live on the fleet. The profile lock
//! is held server-side, so a launch would be refused anyway; this makes the
//! refusal instant and legible instead of a round trip and a raw string.
//! - [`HandoffState::PendingSync`]: the session is over, the lock is released,
//! and the work is sitting in cloud storage. This is the window that used to
//! be wide open.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Duration;
/// Emitted whenever the set of gated profiles changes.
pub const EVENT_REMOTE_HANDOFF: &str = "remote-handoff-changed";
/// Attempts at pulling a finished session's work before giving up for now.
///
/// The entry survives a failure, so "giving up" only means this burst stops;
/// the next stream event, app start or manual sync tries again. What the retries
/// buy is the common case: the profile lock is released server-side a moment
/// before this machine's cached copy of it expires, and a single attempt would
/// hit `Skipped("profile is locked elsewhere")` and leave the user blocked for
/// no reason.
const PULL_ATTEMPTS: u32 = 5;
/// Delay before the second pull attempt. Doubles, capped by [`PULL_RETRY_MAX`].
const PULL_RETRY_BASE: Duration = Duration::from_secs(2);
/// Ceiling on the pull backoff. Above the 30s profile-lock refresh, so a run of
/// attempts is guaranteed to span at least one refresh of the lock cache.
const PULL_RETRY_MAX: Duration = Duration::from_secs(45);
/// Where a profile stands with respect to the fleet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HandoffState {
/// A session is live on the fleet right now.
Running,
/// A session has finished and its work has not been pulled down yet.
PendingSync,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HandoffEntry {
session_id: String,
state: HandoffState,
/// When this entry last changed, unix seconds. Diagnostics only; the gate
/// never expires on its own, because an entry that timed out would reopen
/// exactly the window it exists to close.
observed_at: u64,
}
type Store = HashMap<String, HandoffEntry>;
static STORE: RwLock<Option<Store>> = RwLock::new(None);
fn store_path() -> std::path::PathBuf {
crate::app_dirs::settings_dir().join("remote_handoff.json")
}
fn load_from_disk() -> Store {
let path = store_path();
let Ok(bytes) = std::fs::read(&path) else {
return Store::new();
};
match serde_json::from_slice::<Store>(&bytes) {
Ok(store) => store,
Err(e) => {
// Losing the file means losing the gate, so say so loudly rather than
// starting empty and quietly permitting a launch over pending work.
log::error!(
"Could not read {}: {e}. Profiles with unsynced remote work will not be gated until \
the next session event.",
path.display()
);
Store::new()
}
}
}
fn persist(store: &Store) {
let path = store_path();
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Could not create {}: {e}", parent.display());
return;
}
}
match serde_json::to_vec_pretty(store) {
Ok(bytes) => {
if let Err(e) = crate::app_dirs::write_owner_only(&path, &bytes) {
log::warn!("Could not write {}: {e}", path.display());
}
}
Err(e) => log::warn!("Could not encode the remote handoff store: {e}"),
}
}
fn with_store<T>(f: impl FnOnce(&mut Store) -> T) -> T {
let mut guard = STORE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let store = guard.get_or_insert_with(load_from_disk);
f(store)
}
/// Apply a mutation, and persist plus announce it only if it changed anything.
fn mutate(f: impl FnOnce(&mut Store) -> bool) {
let changed = with_store(|store| {
let changed = f(store);
if changed {
persist(store);
}
changed
});
if changed {
announce();
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn announce() {
let _ = crate::events::emit(EVENT_REMOTE_HANDOFF, states());
}
/// Every gated profile, for the UI and for one-shot reads.
pub fn states() -> HashMap<String, HandoffState> {
with_store(|store| {
store
.iter()
.map(|(profile_id, entry)| (profile_id.clone(), entry.state))
.collect()
})
}
/// Where this profile stands, if it is gated at all.
pub fn state_for(profile_id: &str) -> Option<HandoffState> {
with_store(|store| store.get(profile_id).map(|entry| entry.state))
}
/// The session currently holding this profile on the fleet, if any.
///
/// Answers for a `provisioning` session too, which the drivable-session index
/// deliberately does not. Stopping a session that has not finished coming up is
/// the single most common thing a user does after starting one by mistake, and
/// an index built for "where do I attach a CDP client" cannot serve it.
pub fn running_session_for_profile(profile_id: &str) -> Option<String> {
with_store(|store| {
store
.get(profile_id)
.filter(|entry| entry.state == HandoffState::Running)
.map(|entry| entry.session_id.clone())
})
}
/// Which profile a session belongs to, as this machine last recorded it.
///
/// The backend's stop reply carries a session id and a duration but no profile,
/// and the caller that pressed stop needs to know whose work to pull. Reading it
/// back from the gate avoids a second round trip for something already known.
pub fn profile_for_session(session_id: &str) -> Option<String> {
with_store(|store| {
store
.iter()
.find(|(_, entry)| entry.session_id == session_id)
.map(|(profile_id, _)| profile_id.clone())
})
}
/// Record that a session is live on the fleet for this profile.
///
/// Written to disk immediately, and this is the point of the whole store: if the
/// app is closed while a session runs, nothing on restart would otherwise
/// distinguish "this profile is fine" from "a host has been writing to this
/// profile for the last hour".
pub fn note_running(profile_id: &str, session_id: &str) {
mutate(|store| {
let entry = store.get(profile_id);
if entry
.is_some_and(|held| held.state == HandoffState::Running && held.session_id == session_id)
{
return false;
}
store.insert(
profile_id.to_string(),
HandoffEntry {
session_id: session_id.to_string(),
state: HandoffState::Running,
observed_at: now_secs(),
},
);
true
});
}
/// Record that a session has finished and its work is waiting in cloud storage.
///
/// Returns whether this call is the one that moved the profile into
/// `PendingSync`, so the caller starts exactly one pull for a transition that
/// the stream may well deliver more than once.
pub fn note_ended(profile_id: &str, session_id: &str) -> bool {
let mut transitioned = false;
mutate(|store| {
// Only a session this machine was watching can hand work over to it.
//
// No entry means one of two things and both say "do nothing": the pull for
// this session already completed and cleared the gate, or this machine
// never held the profile. The backend's listing returns closed sessions
// alongside live ones, so the snapshot on every reconnect replays each
// finished session — treating those as fresh handoffs would gate a
// perfectly current profile on every app start, and keep it blocked for as
// long as the machine happened to be offline.
let Some(entry) = store.get(profile_id) else {
return false;
};
// A late `closed` for a session that has already been replaced by a newer
// one must not mark the newer one's profile as finished.
if entry.session_id != session_id {
return false;
}
if entry.state == HandoffState::PendingSync {
return false;
}
transitioned = true;
store.insert(
profile_id.to_string(),
HandoffEntry {
session_id: session_id.to_string(),
state: HandoffState::PendingSync,
observed_at: now_secs(),
},
);
true
});
transitioned
}
/// Drop the gate. Called only after a pull has actually completed.
pub fn clear(profile_id: &str) {
mutate(|store| store.remove(profile_id).is_some());
}
/// Bring stored `Running` entries back in line with what the backend reports.
///
/// The stream is how a transition normally arrives, and it cannot deliver one
/// that happened while the app was shut. Any profile this machine last saw
/// running, whose session the backend no longer reports as live, finished
/// without being observed — and its work is sitting in cloud storage unpulled.
/// Returns the profiles that just moved into `PendingSync`.
pub fn reconcile(live_session_ids: &std::collections::HashSet<String>) -> Vec<String> {
let mut ended = Vec::new();
mutate(|store| {
let stale: Vec<(String, String)> = store
.iter()
.filter(|(_, entry)| entry.state == HandoffState::Running)
.filter(|(_, entry)| !live_session_ids.contains(&entry.session_id))
.map(|(profile_id, entry)| (profile_id.clone(), entry.session_id.clone()))
.collect();
for (profile_id, session_id) in stale {
log::info!(
"Remote session {session_id} for profile {profile_id} ended while this machine was not \
watching; its work is still in cloud storage"
);
store.insert(
profile_id.clone(),
HandoffEntry {
session_id,
state: HandoffState::PendingSync,
observed_at: now_secs(),
},
);
ended.push(profile_id);
}
!ended.is_empty()
});
ended
}
/// Refuse a local launch that would run over unsynced remote work.
///
/// Returns the `{"code":…}` string a Tauri command and the REST layer both
/// surface. Every local launch path calls this: the two that did not are how a
/// profile could be opened locally while a host was still writing to it.
pub fn ensure_local_launch_allowed(profile_id: &str) -> Result<(), String> {
match state_for(profile_id) {
None => Ok(()),
Some(HandoffState::Running) => Err(crate::backend_error("PROFILE_RUNNING_REMOTELY")),
Some(HandoffState::PendingSync) => Err(crate::backend_error("PROFILE_REMOTE_SYNC_PENDING")),
}
}
/// Restart the pull for every profile still waiting on one.
///
/// A pull can fail for as long as the machine is offline, and its retries are
/// bounded, so without this a profile could stay blocked from launching until
/// the user found the manual sync button. Called whenever the app has a cloud
/// session again, which is exactly when a previously impossible pull becomes
/// possible.
pub fn resume_pending_pulls(app_handle: &tauri::AppHandle) {
let pending: Vec<String> = with_store(|store| {
store
.iter()
.filter(|(_, entry)| entry.state == HandoffState::PendingSync)
.map(|(profile_id, _)| profile_id.clone())
.collect()
});
for profile_id in pending {
log::info!("Resuming the post-session pull for profile {profile_id}");
schedule_pull(app_handle.clone(), profile_id);
}
}
/// Pull one profile's finished session down, then lift its gate.
///
/// Spawned rather than awaited by its callers: a stream frame and a stop button
/// must not block on a transfer that can take minutes. The gate stays up for the
/// whole attempt, so there is no window in which the user can open the stale
/// copy while this is in flight.
pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) {
tauri::async_runtime::spawn(async move {
for attempt in 0..PULL_ATTEMPTS {
if state_for(&profile_id) != Some(HandoffState::PendingSync) {
// A new session started, or another pull got there first.
return;
}
if attempt > 0 {
let delay = PULL_RETRY_BASE
.saturating_mul(1u32 << (attempt - 1).min(16))
.min(PULL_RETRY_MAX);
tokio::time::sleep(delay).await;
}
match crate::sync::pull_profile_after_remote_session(&app_handle, &profile_id).await {
Ok(outcome) if outcome.is_completed() => {
log::info!("Pulled remote session work for profile {profile_id}");
clear(&profile_id);
return;
}
Ok(crate::sync::ProfileSyncOutcome::Skipped(reason)) => {
log::info!("Post-session pull for profile {profile_id} did nothing ({reason}); retrying");
}
Ok(_) => unreachable!("is_completed covers every completed outcome"),
Err(e) => {
log::warn!("Post-session pull for profile {profile_id} failed: {e}");
}
}
}
log::warn!(
"Could not pull remote session work for profile {profile_id} yet; it stays blocked from \
launching locally until the pull succeeds"
);
});
}
/// Serialises every test that can reach [`STORE`], wherever it lives.
///
/// `remote_session`'s tests drive session transitions through `note_running`
/// and `note_ended`, so they mutate this module's global store too — with the
/// same `p1`/`p2` fixture ids. Two mutexes meant the two groups could interleave
/// and clobber each other, which showed up as an intermittent failure in the
/// suite guarding a data-loss bug.
#[cfg(test)]
pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Take the store lock and start from an empty store. Callers must hold the
/// returned guard for the whole test.
#[cfg(test)]
pub(crate) fn lock_for_test() -> std::sync::MutexGuard<'static, ()> {
let lock = TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*STORE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
lock
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Point the store at a scratch directory and start it empty.
///
/// Everything returned must outlive the test body: dropping the guard
/// restores the real data directory, and a test that let it drop early would
/// write a gate file into the developer's own app data. `TEST_DATA_DIR` is
/// thread-local but [`STORE`] is process-global, so [`lock_for_test`] is what
/// keeps two tests from sharing one store while pointing at different
/// directories.
fn isolated() -> (
tempfile::TempDir,
crate::app_dirs::TestDirGuard,
std::sync::MutexGuard<'static, ()>,
) {
let lock = lock_for_test();
let dir = tempfile::TempDir::new().expect("a scratch directory");
let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
// Re-taken after the data dir is redirected, so nothing loads from the
// real one.
*STORE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
(dir, guard, lock)
}
#[test]
fn a_live_session_blocks_a_local_launch() {
let _iso = isolated();
note_running("p1", "s1");
let err = ensure_local_launch_allowed("p1").expect_err("a live session must block a launch");
assert!(err.contains("PROFILE_RUNNING_REMOTELY"));
}
#[test]
fn a_finished_session_still_blocks_until_the_work_is_pulled() {
// The whole point. The profile lock is released the moment the session
// closes, so without this the user can open the stale copy and the next
// sync deletes everything the host wrote.
let _iso = isolated();
note_running("p1", "s1");
assert!(note_ended("p1", "s1"));
let err = ensure_local_launch_allowed("p1").expect_err("pending work must block a launch");
assert!(err.contains("PROFILE_REMOTE_SYNC_PENDING"));
clear("p1");
assert!(ensure_local_launch_allowed("p1").is_ok());
}
#[test]
fn an_ungated_profile_is_not_blocked() {
let _iso = isolated();
note_running("p1", "s1");
assert!(ensure_local_launch_allowed("p2").is_ok());
}
#[test]
fn the_end_transition_is_reported_once_however_often_the_frame_arrives() {
// The stream re-delivers a snapshot on every reconnect, and `closed` can
// arrive alongside it. Starting a pull per frame would run several
// concurrent transfers of the same profile.
let _iso = isolated();
note_running("p1", "s1");
assert!(note_ended("p1", "s1"));
assert!(!note_ended("p1", "s1"));
assert!(!note_ended("p1", "s1"));
}
#[test]
fn a_closed_session_this_machine_never_watched_does_not_gate_anything() {
// `listForUser` returns closed sessions next to live ones, so the snapshot
// on every reconnect replays every session that ever finished. Treating
// those as fresh handoffs would block the Run button on a perfectly current
// profile at each app start, and block it indefinitely while offline.
let _iso = isolated();
assert!(!note_ended("p1", "s-finished-last-week"));
assert_eq!(state_for("p1"), None);
assert!(ensure_local_launch_allowed("p1").is_ok());
}
#[test]
fn a_pulled_profile_is_not_re_gated_by_a_replayed_close() {
// Same frame, one step later: the pull completed and cleared the gate. The
// next reconnect must not put it back.
let _iso = isolated();
note_running("p1", "s1");
note_ended("p1", "s1");
clear("p1");
assert!(!note_ended("p1", "s1"));
assert!(ensure_local_launch_allowed("p1").is_ok());
}
#[test]
fn a_late_close_for_a_replaced_session_does_not_gate_the_new_one() {
// Session s1 finished and was pulled; s2 is now live on the same profile. A
// straggling `closed` for s1 must not declare s2's profile finished, or the
// gate lifts while a host is still writing.
let _iso = isolated();
note_running("p1", "s2");
assert!(!note_ended("p1", "s1"));
assert_eq!(state_for("p1"), Some(HandoffState::Running));
}
#[test]
fn a_session_that_ended_while_the_app_was_shut_is_recovered() {
// Nothing streams a transition to a process that is not running. Without
// this the profile reads as still-running for ever and can never be
// launched again, and its work is never pulled.
let _iso = isolated();
note_running("p1", "s1");
let live: HashSet<String> = HashSet::new();
assert_eq!(reconcile(&live), vec!["p1".to_string()]);
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
}
#[test]
fn reconcile_leaves_a_session_that_is_genuinely_still_live() {
let _iso = isolated();
note_running("p1", "s1");
let live: HashSet<String> = ["s1".to_string()].into_iter().collect();
assert!(reconcile(&live).is_empty());
assert_eq!(state_for("p1"), Some(HandoffState::Running));
}
#[test]
fn reconcile_does_not_reopen_a_pending_profile() {
// `PendingSync` is not a session state and no listing will ever contain it.
// Re-deriving it from the snapshot would report the same handoff as new on
// every reconnect and start a pull each time.
let _iso = isolated();
note_running("p1", "s1");
note_ended("p1", "s1");
let live: HashSet<String> = HashSet::new();
assert!(reconcile(&live).is_empty());
}
#[test]
fn the_gate_survives_a_restart() {
// Held on disk precisely because the dangerous window outlives the process:
// an app killed mid-session comes back with no memory of it.
let (_dir, _guard, _lock) = isolated();
note_running("p1", "s1");
note_ended("p1", "s1");
*STORE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
}
}
+698 -2
View File
@@ -8,7 +8,9 @@
use crate::cloud_errors::{self, FailureCodes};
use crate::profile::types::BrowserProfile;
use crate::remote_exit::ExitReachability;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
@@ -114,6 +116,54 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String {
format!("run-remote:{profile_id}:{attempt}")
}
/// Whether this profile's exit rules out running it on a leased host.
///
/// A session runs on a fleet host that pulls the profile — and its proxy record
/// — out of the user's sync namespace, rewriting no addresses along the way. A
/// proxy stored as `127.0.0.1:8080` therefore arrives meaning THAT host's
/// loopback: the browser either cannot connect and the leased hour is burned on
/// a session that never worked, or it falls through and the user's identity
/// egresses from our datacenter. The Cookie Bot has refused this since
/// `remote_exit` existed; interactive sessions take the same profile onto the
/// same hosts and did not, so the same mistake cost a leased hour here.
///
/// A profile with NO exit at all is deliberately allowed through. The Cookie
/// Bot refuses that separately because a night of unattended browsing from a
/// hosting ASN damages an identity, but an interactive session is a person at a
/// keyboard who chose to open this profile and can see where it comes out —
/// and no rule has ever required an exit here. Refusing it would be a new
/// product restriction wearing this bug's error code.
///
/// Split out from the launch because that is the only testable seam:
/// `exit_reachability` reads this machine's proxy and VPN stores and the launch
/// itself needs a fleet.
fn local_exit_refusal(verdict: &ExitReachability) -> Option<RemoteSessionError> {
match verdict {
// An address anyone can dial, so the leased host can dial it too.
ExitReachability::Remote => None,
// See the second paragraph above: allowed on purpose, not overlooked.
ExitReachability::None => None,
// `LocalOnly`, plus `Unknown` — which `remote_exit` produces when it could
// not read the config and which fails closed by design, because "we could
// not confirm it" guessed as "yes" is the failure this whole check exists
// to stop.
unusable => {
// The prose names the offending host, which belongs in the log where
// support can read it. The toast gets the code so it stays translated.
if let Some(detail) = unusable.refusal_detail() {
log::warn!("Refusing an interactive remote session: {detail}");
}
// `Other` rather than a typed variant: the other three are each pinned to
// a status and a meaning — "the fleet is busy", "already open somewhere",
// "not on your plan" — and this refusal is none of them. The code in the
// body is what every surface renders.
Some(RemoteSessionError::Other(
serde_json::json!({ "code": "REMOTE_REQUIRES_REMOTE_EXIT_NODE" }).to_string(),
))
}
}
}
/// Ask donutbrowser-infra to start a remote session for this profile.
///
/// Goes through `api_call_with_retry` so an expired access token is refreshed
@@ -132,12 +182,20 @@ pub async fn start_remote_session(
.to_string();
let profile_id = profile.id.to_string();
// Checked here, before the request: the backend is told which profile to
// start but never sees the proxy record, so it cannot derive this — and by
// the time it could, an hour is already leased and billed. Resolving a proxy
// id to an address is only possible on the machine that stores it.
if let Some(refusal) = local_exit_refusal(&crate::cookie_bot::exit_reachability(profile)) {
return Err(refusal);
}
// One key for this user action: a retry inside api_call_with_retry must
// de-duplicate rather than open a second browser on the same profile.
let key = idempotency_key(&profile_id, &uuid::Uuid::new_v4().to_string());
let endpoint = format!("{}/api/remote-sessions", crate::cloud_auth::CLOUD_API_URL);
crate::cloud_auth::CLOUD_AUTH
let outcome = crate::cloud_auth::CLOUD_AUTH
.api_call_with_retry(|token| {
let endpoint = endpoint.clone();
let body = StartRemoteRequest {
@@ -169,7 +227,14 @@ pub async fn start_remote_session(
}
})
.await
.map_err(|e| classify_error_string(&e))
.map_err(|e| classify_error_string(&e))?;
// Gate the profile here rather than waiting for the stream to say so. A host
// starts pulling this profile the instant the backend accepts, and the first
// transition can arrive seconds later or, on a machine whose stream is down,
// not at all. Those seconds are enough for a user to press Run.
note_session_started(&profile.id.to_string(), &outcome.session_id);
Ok(outcome)
}
/// What the backend returns when a session is stopped.
@@ -181,6 +246,30 @@ pub struct EndRemoteSessionOutcome {
pub billed_seconds: u64,
}
/// Gate a profile the moment a launch is accepted, and pull when one is stopped.
///
/// The event stream is the normal way this machine learns a session's state, but
/// it is not the only way a session starts or ends and it is not guaranteed to
/// be connected. Both of these are called directly by the launch and stop paths
/// so the gate never depends on a socket being up: a launch whose first
/// transition is missed would leave the profile openable locally while a host
/// wrote to it, and a stop whose `closed` frame is missed would leave the
/// session's work sitting in cloud storage with nothing to pull it.
pub fn note_session_started(profile_id: &str, session_id: &str) {
crate::remote_handoff::note_running(profile_id, session_id);
}
pub fn note_session_stopped(app: &AppHandle, session_id: &str) {
let Some(profile_id) = crate::remote_handoff::profile_for_session(session_id) else {
// A session this machine never saw start. There is nothing recorded to
// pull for, and inventing a profile id would gate the wrong profile.
return;
};
if crate::remote_handoff::note_ended(&profile_id, session_id) {
crate::remote_handoff::schedule_pull(app.clone(), profile_id);
}
}
/// Ask donutbrowser-infra to stop a remote session.
///
/// Without this the only thing that ends a session is the fleet's own two-hour
@@ -343,6 +432,257 @@ async fn get_json<T: serde::de::DeserializeOwned>(
.map_err(|e| classify_error_string(&e))
}
// --- Driving a session ------------------------------------------------------
/// Where to attach a CDP client for one session.
///
/// The descriptor is deliberately OPAQUE and server-decided. The desktop knows
/// nothing about the fleet — not its hostname, not its paths, not a credential
/// it would accept — and switches only on `auth`. That is what lets the server
/// move the endpoint, or hand out a different kind of credential, without a
/// desktop release; a hard-coded URL in a shipped binary could not be moved at
/// all.
#[derive(Debug, Clone, Deserialize)]
pub struct CdpEndpoint {
#[serde(default)]
pub session_id: String,
pub ws_url: String,
/// Wire protocol the endpoint speaks.
#[serde(default)]
pub protocol: String,
/// How to authenticate: `bearer` means the same access token used for REST.
#[serde(default)]
pub auth: String,
}
/// The only credential scheme this build can present.
const AUTH_BEARER: &str = "bearer";
/// The only relay protocol this build speaks.
const PROTOCOL_CDP_RELAY_1: &str = "cdp-relay/1";
/// Endpoints already resolved, keyed by session id.
///
/// A session's endpoint does not move while it lives, and every tool call would
/// otherwise pay a cloud round trip before it could send its first byte.
static CDP_ENDPOINTS: Mutex<Option<HashMap<String, CdpEndpoint>>> = Mutex::new(None);
/// Ask the backend where to attach for `session_id`.
pub async fn cdp_endpoint(session_id: &str) -> Result<CdpEndpoint, RemoteSessionError> {
if let Some(cached) = with_endpoints(|map| map.get(session_id).cloned()) {
return Ok(cached);
}
let endpoint = format!(
"{}/api/remote-sessions/{}/cdp",
crate::cloud_auth::CLOUD_API_URL,
urlencoding::encode(session_id)
);
let mut resolved: CdpEndpoint = get_json(endpoint).await?;
if resolved.session_id.is_empty() {
resolved.session_id = session_id.to_string();
}
if let Some(reason) = unsupported_descriptor(&resolved) {
return Err(RemoteSessionError::Other(reason));
}
with_endpoints(|map| map.insert(session_id.to_string(), resolved.clone()));
Ok(resolved)
}
/// Why this build cannot use a descriptor, if it cannot.
///
/// A scheme or protocol this version does not implement has to fail loudly.
/// Guessing at a credential scheme would send the user's access token somewhere
/// it was never meant to go, and ignoring the fields would present the wrong
/// credential on a wire expecting another — both of which read as "remote
/// driving is broken" rather than "this app is out of date".
///
/// An empty field means the server stated nothing, which is how a descriptor
/// that predates the field looks; the historic behaviour is then the answer.
fn unsupported_descriptor(endpoint: &CdpEndpoint) -> Option<String> {
if !endpoint.auth.is_empty() && endpoint.auth != AUTH_BEARER {
return Some(format!(
"this version cannot attach to a remote browser using {:?} authentication; update Donut Browser",
endpoint.auth
));
}
if !endpoint.protocol.is_empty() && endpoint.protocol != PROTOCOL_CDP_RELAY_1 {
return Some(format!(
"this version does not speak {:?}; update Donut Browser",
endpoint.protocol
));
}
None
}
fn with_endpoints<T>(f: impl FnOnce(&mut HashMap<String, CdpEndpoint>) -> T) -> T {
let mut guard = CDP_ENDPOINTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f(guard.get_or_insert_with(HashMap::new))
}
fn forget_endpoint(session_id: &str) {
with_endpoints(|map| map.remove(session_id));
}
/// The access token a relay attach presents.
///
/// One place, so the credential a WebSocket carries is provably the same one
/// every REST call already carries, and no second copy of the load-and-check
/// logic can drift from it.
pub fn access_token_for_cdp() -> Result<String, String> {
crate::cloud_auth::CloudAuthManager::load_access_token()?
.filter(|token| !token.is_empty())
.ok_or_else(|| "not signed in to Donut cloud".to_string())
}
/// Sessions that can be driven right now, keyed by the profile they hold.
///
/// Maintained from the event stream so deciding "is this profile running on the
/// fleet?" costs a lock rather than a cloud round trip on every tool call.
static LIVE_BY_PROFILE: Mutex<Option<HashMap<String, RemoteSessionState>>> = Mutex::new(None);
/// Whether the stream has delivered a snapshot and has not dropped since.
///
/// Without this the index cannot distinguish "no session for that profile" from
/// "nothing has told us about any session yet", and the second answered as the
/// first is exactly how a live remote profile reports itself as not running.
static INDEX_AUTHORITATIVE: AtomicBool = AtomicBool::new(false);
fn with_index<T>(f: impl FnOnce(&mut HashMap<String, RemoteSessionState>) -> T) -> T {
let mut guard = LIVE_BY_PROFILE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f(guard.get_or_insert_with(HashMap::new))
}
/// A session that is up AND attachable.
///
/// `provisioning` and `ready` are both "the browser is not there yet"; treating
/// either as drivable is what makes a client attach into a connection that
/// never establishes.
pub fn is_drivable(session: &RemoteSessionState) -> bool {
session.state == "live" && session.cdp_ready
}
/// A session that will never write to the profile again.
///
/// Deliberately NOT the negation of [`is_drivable`]. A `provisioning` session
/// has already taken the profile lock and its host is about to pull the profile
/// down and launch a browser on it, so it owns the profile every bit as much as
/// a `live` one does — it is simply not attachable yet. Treating "not drivable"
/// as "finished" would lift the local launch gate during the one minute a host
/// spends starting up, which is the window in which two writers do the most
/// damage.
pub fn is_terminal(session: &RemoteSessionState) -> bool {
matches!(session.state.as_str(), "closed" | "error")
}
/// Apply one session to the index, and to the local launch gate.
///
/// A session that stopped being drivable is removed, but only by the session
/// that owns the slot: a late `closed` for a finished session must not evict
/// the live one that replaced it.
fn index_session(app: Option<&AppHandle>, session: &RemoteSessionState) {
let Some(profile_id) = session.profile_id.clone() else {
return;
};
// The gate is maintained from the same frames as the index, because these are
// the only frames that exist. It is deliberately keyed off `is_terminal`
// rather than `is_drivable`: a provisioning host already owns the profile.
if is_terminal(session) {
if crate::remote_handoff::note_ended(&profile_id, &session.session_id) {
if let Some(app) = app {
crate::remote_handoff::schedule_pull(app.clone(), profile_id.clone());
}
}
} else {
crate::remote_handoff::note_running(&profile_id, &session.session_id);
}
if is_drivable(session) {
with_index(|map| map.insert(profile_id, session.clone()));
return;
}
forget_endpoint(&session.session_id);
with_index(|map| {
let owns_slot = map
.get(&profile_id)
.is_some_and(|held| held.session_id == session.session_id);
if owns_slot {
map.remove(&profile_id);
}
});
}
/// Replace the whole index from a full listing, and reconcile the launch gate.
fn reindex(app: Option<&AppHandle>, sessions: &[RemoteSessionState]) {
// Every session the backend still considers unfinished. A profile this
// machine last saw running whose session is not in here finished while
// nothing was watching — its work is in cloud storage and has not been pulled.
let unfinished: std::collections::HashSet<String> = sessions
.iter()
.filter(|session| !is_terminal(session))
.map(|session| session.session_id.clone())
.collect();
for session in sessions {
index_session(app, session);
}
for profile_id in crate::remote_handoff::reconcile(&unfinished) {
if let Some(app) = app {
crate::remote_handoff::schedule_pull(app.clone(), profile_id);
}
}
let next: HashMap<String, RemoteSessionState> = sessions
.iter()
.filter(|session| is_drivable(session))
.filter_map(|session| {
session
.profile_id
.clone()
.map(|profile_id| (profile_id, session.clone()))
})
.collect();
let live: std::collections::HashSet<&str> = next
.values()
.map(|session| session.session_id.as_str())
.collect();
with_endpoints(|map| map.retain(|session_id, _| live.contains(session_id.as_str())));
with_index(|map| *map = next);
}
/// The drivable session holding `profile_id`, if there is one.
///
/// Consults the in-process index first. Only when the stream is not delivering
/// transitions does it spend a cloud round trip, because in that state the
/// index cannot be trusted to be complete and answering "not running" from it
/// would hide a session the user is already paying for.
pub async fn live_session_for_profile(profile_id: &str) -> Option<RemoteSessionState> {
if let Some(session) = with_index(|map| map.get(profile_id).cloned()) {
return Some(session);
}
if INDEX_AUTHORITATIVE.load(Ordering::SeqCst) {
return None;
}
match list_remote_sessions().await {
Ok(sessions) => {
reindex(None, &sessions);
with_index(|map| map.get(profile_id).cloned())
}
Err(e) => {
log::debug!("Could not refresh remote sessions while resolving a CDP target: {e}");
None
}
}
}
// --- Live state, without polling -------------------------------------------
/// A session transition. Payload is the session as the backend sees it.
@@ -550,6 +890,10 @@ pub fn start_session_events(app: AppHandle) {
/// Stop receiving. Safe to call when nothing is running.
pub fn stop_session_events() {
// Cleared unconditionally: unsubscribing is what sign-out does, and an index
// left marked authoritative would keep answering from state nothing is
// maintaining any more.
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
if !STREAM_RUNNING.swap(false, Ordering::SeqCst) {
return;
}
@@ -723,6 +1067,7 @@ fn dispatch_frame(app: &AppHandle, frame: &SseFrame) {
let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) else {
return;
};
apply_to_index(Some(app), target, &payload);
use tauri::Emitter;
if let Err(e) = app.emit(target, payload) {
@@ -730,7 +1075,47 @@ fn dispatch_frame(app: &AppHandle, frame: &SseFrame) {
}
}
/// Keep the drivable-session index in step with what the stream just said.
///
/// The same frames that tell the frontend a session went live are the only
/// thing that can tell the CDP resolver so without polling, and a resolver that
/// polls would put a cloud round trip in front of every automation call.
pub fn apply_to_index(app: Option<&AppHandle>, target: &str, payload: &serde_json::Value) {
if target == EVENT_SESSION_SNAPSHOT {
let Some(array) = payload.get("sessions").and_then(|v| v.as_array()) else {
// Marking the index authoritative off a frame that carried no list would
// answer "no session" for every profile until the next reconnect.
log::warn!("Ignoring a remote-session snapshot that carried no session list");
return;
};
let mut sessions = Vec::with_capacity(array.len());
for value in array {
match serde_json::from_value::<RemoteSessionState>(value.clone()) {
Ok(session) => sessions.push(session),
Err(e) => log::warn!("Skipping an undecodable session in the snapshot: {e}"),
}
}
reindex(app, &sessions);
INDEX_AUTHORITATIVE.store(true, Ordering::SeqCst);
return;
}
if target == EVENT_SESSION_STATE {
match serde_json::from_value::<RemoteSessionState>(payload.clone()) {
Ok(session) => index_session(app, &session),
Err(e) => log::warn!("Ignoring an undecodable session transition: {e}"),
}
}
}
fn emit_stream_status(app: &AppHandle, connected: bool, reason: Option<&str>) {
if !connected {
// A dropped stream means transitions are being missed, so the index stops
// being an answer and becomes a cache: a miss now costs one cloud read
// rather than silently reporting a live session as absent.
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
}
use tauri::Emitter;
let payload = serde_json::json!({ "connected": connected, "reason": reason });
if let Err(e) = app.emit(EVENT_STREAM_STATUS, payload) {
@@ -852,6 +1237,50 @@ mod tests {
);
}
#[test]
fn a_local_only_exit_is_refused_before_a_host_is_leased() {
// The profile and its proxy record are copied onto the fleet unrewritten,
// so this loopback address would mean the FLEET's loopback. Accepting the
// launch bills an hour for a session that cannot reach the user's exit.
let refusal = local_exit_refusal(&ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
source: "proxy",
})
.expect("a loopback proxy is unusable from a leased host");
assert_eq!(
refusal.to_error_json(),
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
);
}
#[test]
fn an_exit_that_could_not_be_read_is_refused_too() {
// `Unknown` is "we could not confirm this works from elsewhere". Treating
// that as a yes reintroduces exactly the burned hour above, so it fails
// closed here as it does everywhere else `remote_exit` is consulted.
let refusal = local_exit_refusal(&ExitReachability::Unknown {
reason: "the profile references a proxy that no longer exists".to_string(),
source: "proxy",
})
.expect("an unreadable exit is not evidence of a reachable one");
assert_eq!(
refusal.to_error_json(),
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
);
}
#[test]
fn a_reachable_exit_and_no_exit_at_all_are_both_allowed_to_launch() {
assert!(local_exit_refusal(&ExitReachability::Remote).is_none());
// Deliberate, and the reason this gate is not simply `!is_remote()`: a
// proxyless interactive session has always been permitted, and refusing it
// with a code that says "your proxy is local" would be both a new product
// rule and a sentence that does not describe the profile.
assert!(local_exit_refusal(&ExitReachability::None).is_none());
}
#[test]
fn a_backend_supplied_code_survives_the_trip_through_the_typed_error() {
// Once infra sends an envelope, its code must win over the status default
@@ -1123,4 +1552,271 @@ mod tests {
stop_session_events();
assert!(!session_events_running());
}
// --- The drivable-session index ------------------------------------------
//
// This index is what lets an automation call decide "is this profile running
// on the fleet?" without a cloud round trip. Everything below drives it
// through the SAME two steps production uses — decode the wire, route the
// frame, apply it — because the whole class of bug this replaced came from a
// test that agreed with the client and neither agreeing with the server.
/// The statics below are process-wide, and `cargo test` runs these threads in
/// parallel. Without this every index test would be racing every other one.
static INDEX_TESTS: Mutex<()> = Mutex::new(());
fn index_test<T>(body: impl FnOnce() -> T) -> T {
let _guard = INDEX_TESTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// Applying a session transition also drives `remote_handoff`: it mutates
// that module's process-global store and persists the launch gate to the
// data directory. Its lock keeps the two test groups from clobbering each
// other's `p1`/`p2` fixtures, and the scratch directory keeps the gate file
// out of the developer's own app data.
let _handoff = crate::remote_handoff::lock_for_test();
let dir = tempfile::TempDir::new().expect("a scratch directory");
let _data_dir = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
with_index(|map| map.clear());
with_endpoints(|map| map.clear());
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
body()
}
/// Decode, route and apply a literal wire capture, exactly as
/// `dispatch_frame` does minus the emit to the frontend.
fn feed(bytes: &[u8]) {
let mut decoder = SseDecoder::new();
for frame in decoder.push(bytes) {
if let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) {
apply_to_index(None, target, &payload);
}
}
}
fn indexed(profile_id: &str) -> Option<RemoteSessionState> {
with_index(|map| map.get(profile_id).cloned())
}
fn transition(session_id: &str, profile_id: &str, state: &str, cdp_ready: bool) -> Vec<u8> {
format!(
"data: {{\"type\":\"state\",\"session\":{{\"session_id\":\"{session_id}\",\"profile_id\":\"{profile_id}\",\"state\":\"{state}\",\"cdp_ready\":{cdp_ready}}}}}\n\n"
)
.into_bytes()
}
#[test]
fn a_session_becoming_drivable_is_indexed_by_the_profile_it_holds() {
index_test(|| {
feed(&transition("sess-1", "p1", "live", true));
let held = indexed("p1").expect("a live session must be resolvable by profile");
assert_eq!(held.session_id, "sess-1");
});
}
#[test]
fn a_browser_that_is_up_but_not_attachable_is_not_offered_for_driving() {
index_test(|| {
// `ready` without CDP is a browser that exists and cannot be driven.
// Offering it is what makes a client attach into a connection that never
// establishes, and then blame the fleet for the timeout.
feed(&transition("sess-1", "p1", "ready", false));
assert!(indexed("p1").is_none());
feed(&transition("sess-1", "p1", "provisioning", false));
assert!(indexed("p1").is_none());
});
}
#[test]
fn a_session_that_closes_frees_the_profile_and_forgets_its_endpoint() {
index_test(|| {
feed(&transition("sess-1", "p1", "live", true));
with_endpoints(|map| {
map.insert(
"sess-1".to_string(),
CdpEndpoint {
session_id: "sess-1".to_string(),
ws_url: "wss://example/cdp".to_string(),
protocol: PROTOCOL_CDP_RELAY_1.to_string(),
auth: AUTH_BEARER.to_string(),
},
)
});
feed(&transition("sess-1", "p1", "closed", false));
assert!(indexed("p1").is_none());
// A cached endpoint for a dead session would be handed to the next
// attach, which would then fail against a relay that has nothing left to
// relay to.
assert!(with_endpoints(|map| map.get("sess-1").cloned()).is_none());
});
}
#[test]
fn a_late_close_for_a_finished_session_does_not_evict_the_one_that_replaced_it() {
index_test(|| {
feed(&transition("sess-1", "p1", "live", true));
feed(&transition("sess-1", "p1", "closed", false));
feed(&transition("sess-2", "p1", "live", true));
// Out-of-order frames are normal: the reconciler polls the fleet while
// the user is already starting the next session. A stale close arriving
// after the new session went live must not make a working browser
// unreachable.
feed(&transition("sess-1", "p1", "closed", false));
assert_eq!(
indexed("p1").map(|s| s.session_id),
Some("sess-2".to_string())
);
});
}
#[test]
fn the_opening_snapshot_replaces_the_index_and_makes_it_authoritative() {
index_test(|| {
feed(&transition("stale", "p-gone", "live", true));
feed(
concat!(
r#"data: {"type":"snapshot","at":"2026-08-03T00:00:00.000Z","sessions":["#,
r#"{"session_id":"sess-1","profile_id":"p1","state":"live","cdp_ready":true},"#,
r#"{"session_id":"sess-2","profile_id":"p2","state":"ready","cdp_ready":false}]}"#,
"\n\n"
)
.as_bytes(),
);
assert_eq!(
indexed("p1").map(|s| s.session_id),
Some("sess-1".to_string())
);
// Not attachable, so not in the index even though the snapshot listed it.
assert!(indexed("p2").is_none());
// A session the snapshot did not mention is gone, however live the index
// last believed it to be.
assert!(indexed("p-gone").is_none());
assert!(INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
});
}
#[test]
fn a_snapshot_carrying_no_session_list_does_not_blind_the_resolver() {
index_test(|| {
feed(&transition("sess-1", "p1", "live", true));
// Trusting a malformed snapshot would answer "no session" for every
// profile until the next reconnect, which is exactly the blindness the
// index exists to remove.
apply_to_index(None, EVENT_SESSION_SNAPSHOT, &serde_json::json!({}));
assert_eq!(
indexed("p1").map(|s| s.session_id),
Some("sess-1".to_string())
);
assert!(!INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
});
}
#[test]
fn one_undecodable_session_does_not_cost_the_whole_snapshot() {
index_test(|| {
feed(
concat!(
r#"data: {"type":"snapshot","sessions":[{"nonsense":true},"#,
r#"{"session_id":"sess-1","profile_id":"p1","state":"live","cdp_ready":true}]}"#,
"\n\n"
)
.as_bytes(),
);
assert_eq!(
indexed("p1").map(|s| s.session_id),
Some("sess-1".to_string())
);
assert!(INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
});
}
#[test]
fn unsubscribing_stops_the_index_being_an_answer() {
index_test(|| {
INDEX_AUTHORITATIVE.store(true, Ordering::SeqCst);
STREAM_RUNNING.store(false, Ordering::SeqCst);
// Sign-out unsubscribes. An index still marked authoritative would keep
// answering from state nothing is maintaining any more, so a session
// started by the next account would report as absent.
stop_session_events();
assert!(!INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
});
}
#[test]
fn a_session_with_no_profile_is_ignored_rather_than_indexed_under_nothing() {
index_test(|| {
feed(b"data: {\"type\":\"state\",\"session\":{\"session_id\":\"s1\",\"state\":\"live\",\"cdp_ready\":true}}\n\n");
assert!(with_index(|map| map.is_empty()));
});
}
// --- The CDP endpoint descriptor -----------------------------------------
#[test]
fn the_endpoint_descriptor_matches_what_the_backend_sends() {
// Pinned against `GET /api/remote-sessions/:id/cdp` in donutbrowser-infra.
// A field name that does not match makes every remote attach fail at the
// decode step, and the desktop reports a live session as undrivable.
let endpoint: CdpEndpoint = serde_json::from_str(
r#"{"session_id":"sess-1",
"ws_url":"wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=sess-1",
"protocol":"cdp-relay/1","auth":"bearer"}"#,
)
.expect("the backend's CDP descriptor must deserialize");
assert_eq!(endpoint.session_id, "sess-1");
assert!(endpoint.ws_url.starts_with("wss://"));
assert!(unsupported_descriptor(&endpoint).is_none());
}
#[test]
fn a_descriptor_this_build_cannot_honour_is_refused_rather_than_guessed_at() {
// The descriptor is opaque and server-decided so the endpoint can move
// without a desktop release. The other side of that bargain is that a
// scheme this build does not implement must say so, not present the user's
// access token on a wire that expected something else.
let ticketed = CdpEndpoint {
session_id: "sess-1".to_string(),
ws_url: "wss://fleet.example/cdp".to_string(),
protocol: PROTOCOL_CDP_RELAY_1.to_string(),
auth: "ticket".to_string(),
};
assert!(unsupported_descriptor(&ticketed)
.expect("an unknown auth scheme must be refused")
.contains("update Donut Browser"));
let future_protocol = CdpEndpoint {
auth: AUTH_BEARER.to_string(),
protocol: "cdp-relay/2".to_string(),
..ticketed
};
assert!(unsupported_descriptor(&future_protocol).is_some());
}
#[test]
fn a_descriptor_that_states_nothing_is_treated_as_todays_behaviour() {
// An older backend that predates the fields must keep working; the fields
// are a forward-compatibility hook, not a required handshake.
let bare: CdpEndpoint = serde_json::from_str(r#"{"ws_url":"wss://example/cdp"}"#)
.expect("a descriptor with only a URL must deserialize");
assert!(bare.session_id.is_empty());
assert!(unsupported_descriptor(&bare).is_none());
}
#[test]
fn only_a_session_that_is_both_live_and_attachable_is_drivable() {
let mut session: RemoteSessionState =
serde_json::from_str(r#"{"session_id":"s1","state":"live","cdp_ready":true}"#).unwrap();
assert!(is_drivable(&session));
session.cdp_ready = false;
assert!(!is_drivable(&session));
session.cdp_ready = true;
session.state = "ready".to_string();
assert!(!is_drivable(&session));
}
}
+12
View File
@@ -54,6 +54,14 @@ pub struct AppSettings {
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
/// Stop blocking launches whose proxy exit disagrees with the fingerprint.
/// Lives here rather than in localStorage because the Rust launch path is
/// what enforces the block and cannot read the frontend's storage.
#[serde(default)]
pub fingerprint_gate_disabled: bool,
/// Stop warning about VPN/proxy extensions found in a profile.
#[serde(default)]
pub vpn_extension_warning_disabled: bool,
#[serde(default)]
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
#[serde(default)]
@@ -96,6 +104,8 @@ impl Default for AppSettings {
mcp_token: None,
language: None,
window_resize_warning_dismissed: false,
fingerprint_gate_disabled: false,
vpn_extension_warning_disabled: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
@@ -1190,6 +1200,8 @@ mod tests {
mcp_token: None,
language: None,
window_resize_warning_dismissed: false,
fingerprint_gate_disabled: false,
vpn_extension_warning_disabled: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
+161 -19
View File
@@ -1,6 +1,8 @@
use super::client::SyncClient;
use super::encryption;
use super::manifest::{compute_diff, generate_manifest, get_cache_path, HashCache, SyncManifest};
use super::manifest::{
compute_diff_with_bias, generate_manifest, get_cache_path, DiffBias, HashCache, SyncManifest,
};
use super::types::*;
use crate::events;
use crate::profile::types::{BrowserProfile, SyncMode};
@@ -20,6 +22,22 @@ use tokio::sync::{Mutex as TokioMutex, Semaphore};
/// (last-write-wins) from a HEAD request without downloading the object body.
const UPDATED_AT_META_KEY: &str = "updated-at";
/// What one profile reconcile actually did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileSyncOutcome {
/// The local directory and the remote copy now agree.
Completed,
/// Nothing was transferred, and the reason is not an error. A caller waiting
/// on the remote copy has NOT got it and must try again.
Skipped(&'static str),
}
impl ProfileSyncOutcome {
pub fn is_completed(&self) -> bool {
matches!(self, Self::Completed)
}
}
lazy_static::lazy_static! {
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
StdMutex::new(HashMap::new());
@@ -91,6 +109,37 @@ fn is_critical_file(path: &str) -> bool {
.any(|pattern| path.contains(pattern))
}
/// How many failed paths to name before collapsing the rest into a count.
const MAX_LISTED_FAILURES: usize = 10;
/// Aggregate a batch of failed transfers into the message the user sees.
///
/// Whatever breaks a sync usually breaks every file the same way — one
/// unreachable storage host, one rejected signature — so the per-file causes
/// were dropped and only the paths survived into the message. That left users
/// staring at a list of filenames with nothing to act on. Carry the first
/// cause through, and stop pasting hundreds of paths into a toast.
fn critical_failure_message(action: &str, failures: &[(String, String)]) -> String {
let listed: Vec<&str> = failures
.iter()
.take(MAX_LISTED_FAILURES)
.map(|(path, _)| path.as_str())
.collect();
let hidden = failures.len().saturating_sub(listed.len());
let files = if hidden > 0 {
format!("{} (and {} more)", listed.join(", "), hidden)
} else {
listed.join(", ")
};
match failures.first() {
Some((_, cause)) => format!(
"Critical files failed to {action}: {files}. Cause: {cause}. Sync aborted to prevent data loss."
),
None => format!("Critical files failed to {action}: {files}. Sync aborted to prevent data loss."),
}
}
/// Validate that a manifest-supplied relative file path is safe to join onto a
/// profile directory before writing/deleting. The manifest is remote-controlled
/// (a self-hosted or compromised sync server, a MITM on a plaintext Regular-mode
@@ -313,7 +362,7 @@ impl SyncProgressTracker {
/// Check if sync is configured (cloud or self-hosted)
pub fn is_sync_configured() -> bool {
// Cloud backup is a plan capability. Every paid plan (incl. the future
// "starter" tier) grants it, but gating on the capability — not just "is paid"
// "solo" tier) grants it, but gating on the capability — not just "is paid"
// — keeps this correct if a plan without cloud backup is ever added.
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
return true;
@@ -450,13 +499,35 @@ impl SyncEngine {
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> SyncResult<()> {
self
.sync_profile_with_bias(app_handle, profile, DiffBias::Auto)
.await
.map(|_| ())
}
/// Reconcile a profile, stating which side wins and whether anything happened.
///
/// The outcome matters to exactly one caller: the pull that follows a remote
/// session. Every skip below returns `Ok(())` from `sync_profile`, so a caller
/// that treated success as "the profile is now current" would clear the local
/// launch gate without having downloaded a single byte — and the user would
/// then open a stale profile over the session's work. `Skipped` says so.
pub async fn sync_profile_with_bias(
&self,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
bias: DiffBias,
) -> SyncResult<ProfileSyncOutcome> {
if profile.is_cross_os() {
log::info!(
"Cross-OS profile: {} ({}) — syncing metadata only",
profile.name,
profile.id
);
return self.sync_cross_os_metadata(app_handle, profile).await;
self.sync_cross_os_metadata(app_handle, profile).await?;
// The browser files are the thing a remote session changes, and a cross-OS
// profile syncs none of them here, so this is not a completed pull.
return Ok(ProfileSyncOutcome::Skipped("cross-OS profile"));
}
// Skip team profiles for self-hosted sync
@@ -466,7 +537,9 @@ impl SyncEngine {
profile.name,
profile.id
);
return Ok(());
return Ok(ProfileSyncOutcome::Skipped(
"team profile, self-hosted sync",
));
}
// Skip if profile is currently running locally
@@ -476,20 +549,21 @@ impl SyncEngine {
profile.name,
profile.id
);
return Ok(());
return Ok(ProfileSyncOutcome::Skipped("profile is running locally"));
}
// Skip if profile is locked by another team member
// Skip if profile is locked by another team member, or by one of this
// user's own remote sessions.
if crate::team_lock::TEAM_LOCK
.is_locked_by_another(&profile.id.to_string())
.await
{
log::info!(
"Skipping sync for profile locked by another team member: {} ({})",
"Skipping sync for profile locked by another holder: {} ({})",
profile.name,
profile.id
);
return Ok(());
return Ok(ProfileSyncOutcome::Skipped("profile is locked elsewhere"));
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
@@ -591,7 +665,7 @@ impl SyncEngine {
.await?;
// Compute diff
let diff = compute_diff(&local_manifest, remote_manifest.as_ref());
let diff = compute_diff_with_bias(&local_manifest, remote_manifest.as_ref(), bias);
if diff.is_empty() {
log::info!("Profile {} is already in sync", profile_id);
@@ -603,7 +677,9 @@ impl SyncEngine {
"status": "synced"
}),
);
return Ok(());
// Nothing to transfer IS a completed reconcile: the local copy already
// matches what the host pushed, which is exactly what the caller waits for.
return Ok(ProfileSyncOutcome::Completed);
}
let upload_bytes: u64 = diff.files_to_upload.iter().map(|f| f.size).sum();
@@ -769,7 +845,7 @@ impl SyncEngine {
);
log::info!("Profile {} synced successfully", profile_id);
Ok(())
Ok(ProfileSyncOutcome::Completed)
}
async fn download_manifest(
@@ -1238,10 +1314,9 @@ impl SyncEngine {
}
if !critical_failures.is_empty() {
let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect();
return Err(SyncError::IoError(format!(
"Critical files failed to upload: {}. Sync aborted to prevent data loss.",
file_list.join(", ")
return Err(SyncError::IoError(critical_failure_message(
"upload",
&critical_failures,
)));
}
@@ -1514,10 +1589,9 @@ impl SyncEngine {
}
if !critical_failures.is_empty() {
let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect();
return Err(SyncError::IoError(format!(
"Critical files failed to download: {}. Sync aborted to prevent data loss.",
file_list.join(", ")
return Err(SyncError::IoError(critical_failure_message(
"download",
&critical_failures,
)));
}
@@ -3546,6 +3620,40 @@ pub async fn trigger_sync_for_profile(
Ok(())
}
/// Pull a profile back down after a remote session wrote to it.
///
/// Not `trigger_sync_for_profile` with a different name. Two things differ, and
/// both of them are the reason the session's work used to be destroyed:
///
/// - The diff is biased to the remote copy. The host has just written the
/// authoritative profile; local mtimes may nonetheless be newer, and under the
/// ordinary rule that uploads the stale copy and deletes the host's files.
/// - The outcome is reported. Every skip inside `sync_profile` returns success,
/// so the caller could otherwise mark the profile current without a byte
/// having moved.
pub async fn pull_profile_after_remote_session(
app_handle: &tauri::AppHandle,
profile_id: &str,
) -> Result<ProfileSyncOutcome, String> {
let engine = SyncEngine::create_from_settings(app_handle)
.await
.map_err(|e| format!("Failed to create sync engine: {e}"))?;
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profile = ProfileManager::instance()
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
engine
.sync_profile_with_bias(app_handle, &profile, DiffBias::PreferRemote)
.await
.map_err(|e| format!("Sync failed: {e}"))
}
#[tauri::command]
pub async fn set_proxy_sync_enabled(
app_handle: tauri::AppHandle,
@@ -4168,6 +4276,40 @@ pub async fn rollover_encryption_for_all_entities(
mod tests {
use super::*;
#[test]
fn test_critical_failure_message_carries_the_cause() {
// A self-hosted server that hands out unreachable presigned URLs fails
// every file with the same connect error. Naming only the files told the
// user nothing about why, which is what made this undiagnosable.
let failures = vec![
(
"Default/Cookies".to_string(),
"Failed to upload Default/Cookies after 3 retries: error sending request".to_string(),
),
("Local State".to_string(), "same".to_string()),
];
let message = critical_failure_message("upload", &failures);
assert!(message.contains("Default/Cookies"));
assert!(message.contains("Local State"));
assert!(message.contains("Cause: Failed to upload Default/Cookies"));
assert!(message.contains("Sync aborted to prevent data loss."));
}
#[test]
fn test_critical_failure_message_collapses_long_lists() {
let failures: Vec<(String, String)> = (0..25)
.map(|i| (format!("file-{i}"), "connect error".to_string()))
.collect();
let message = critical_failure_message("download", &failures);
assert!(message.contains("file-0"));
assert!(message.contains(&format!("file-{}", MAX_LISTED_FAILURES - 1)));
assert!(!message.contains(&format!("file-{MAX_LISTED_FAILURES}")));
assert!(message.contains("(and 15 more)"));
assert!(message.contains("failed to download"));
}
#[test]
fn test_is_safe_manifest_path() {
// Legitimate profile-relative paths are accepted.
+101 -6
View File
@@ -414,11 +414,41 @@ impl ManifestDiff {
}
/// Compute what needs to be synced between local and remote
/// Which side a sync should believe when both have moved.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DiffBias {
/// Newest `updated_at` wins. What an ordinary background sync uses.
#[default]
Auto,
/// Remote wins regardless of timestamps.
///
/// Used for exactly one thing: the pull that follows a remote session. A
/// leased host has just written the authoritative copy of this profile, and
/// the local directory is whatever it was before the session started. If the
/// user launched locally in between, local mtimes are NEWER than the host's
/// push, so `Auto` would upload the stale copy and put every file the host
/// wrote into `files_to_delete_remote` — the whole session's work destroyed,
/// silently. There is no timestamp comparison that gets this right, because
/// the local clock genuinely is later; only the caller knows that the remote
/// copy is the one that matters.
PreferRemote,
}
pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff {
compute_diff_with_bias(local, remote, DiffBias::Auto)
}
pub fn compute_diff_with_bias(
local: &SyncManifest,
remote: Option<&SyncManifest>,
bias: DiffBias,
) -> ManifestDiff {
let mut diff = ManifestDiff::default();
let Some(remote) = remote else {
// No remote manifest - upload everything
// No remote manifest - upload everything. Even under PreferRemote: there is
// no remote copy to prefer, and refusing to upload would leave the profile
// with no cloud copy at all.
diff.files_to_upload = local.files.clone();
return diff;
};
@@ -446,11 +476,14 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
let local_updated = local.updated_at_datetime();
let remote_updated = remote.updated_at_datetime();
let local_is_newer = match (local_updated, remote_updated) {
(Some(l), Some(r)) => l > r,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => true, // Default to uploading
let local_is_newer = match bias {
DiffBias::PreferRemote => false,
DiffBias::Auto => match (local_updated, remote_updated) {
(Some(l), Some(r)) => l > r,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => true, // Default to uploading
},
};
if local_is_newer {
@@ -674,6 +707,68 @@ mod tests {
assert!(diff.files_to_delete_remote.is_empty());
}
/// A manifest with one file, at a stated time.
fn manifest_at(updated_at: &str, files: &[(&str, &str)]) -> SyncManifest {
SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: updated_at.to_string(),
updated_at: updated_at.to_string(),
exclude_globs: vec![],
files: files
.iter()
.map(|(path, hash)| ManifestFileEntry {
path: (*path).to_string(),
size: 10,
mtime: 1000,
hash: (*hash).to_string(),
})
.collect(),
encrypted: false,
}
}
#[test]
fn prefer_remote_downloads_even_though_the_local_clock_is_later() {
// The exact shape of the data-loss bug. A remote session finishes and the
// host pushes the profile; the user then launches locally before the pull
// lands, so every local mtime is newer than the host's write. Under Auto
// that uploads the stale copy and deletes the session's own files.
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "before-session")]);
let remote = manifest_at(
"2026-01-01T00:00:00Z",
&[("Cookies", "after-session"), ("History", "warmed")],
);
let lossy = compute_diff_with_bias(&local, Some(&remote), DiffBias::Auto);
assert_eq!(lossy.files_to_delete_remote, vec!["History".to_string()]);
assert_eq!(lossy.files_to_upload.len(), 1);
let safe = compute_diff_with_bias(&local, Some(&remote), DiffBias::PreferRemote);
assert!(
safe.files_to_delete_remote.is_empty(),
"a post-session pull must never delete what the host just wrote"
);
assert!(safe.files_to_upload.is_empty());
let downloaded: Vec<&str> = safe
.files_to_download
.iter()
.map(|f| f.path.as_str())
.collect();
assert_eq!(downloaded.len(), 2);
assert!(downloaded.contains(&"Cookies"));
assert!(downloaded.contains(&"History"));
}
#[test]
fn prefer_remote_still_uploads_when_there_is_no_remote_copy() {
// Nothing to prefer. Refusing to upload here would leave a profile with no
// cloud copy because a session once ran against it.
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "only-local")]);
let diff = compute_diff_with_bias(&local, None, DiffBias::PreferRemote);
assert_eq!(diff.files_to_upload.len(), 1);
}
#[test]
fn test_compute_diff_detect_changes() {
let old_time = "2024-01-01T00:00:00Z";
+9 -5
View File
@@ -15,12 +15,16 @@ pub use engine::{
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
pull_profile_after_remote_session, request_profile_sync, rollover_encryption_for_all_entities,
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
trigger_sync_for_profile, ProfileSyncOutcome, SyncEngine,
};
pub use manifest::{
compute_diff, compute_diff_with_bias, generate_manifest, DiffBias, HashCache, ManifestDiff,
SyncManifest,
};
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult};
+2 -2
View File
@@ -168,7 +168,7 @@ impl SynchronizerManager {
);
// Launch leader first so it gets focus
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None)
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None, None)
.await
.map_err(|e| format!("Failed to launch leader: {e}"))?;
@@ -179,7 +179,7 @@ impl SynchronizerManager {
let ah = app_handle.clone();
let fp = fp.clone();
set.spawn(async move {
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None)
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None, None)
.await
.map_err(|e| (fp.name.clone(), e.to_string()))
});
+102 -20
View File
@@ -95,8 +95,8 @@ impl ProfileLockManager {
pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> {
let client = Client::new();
let access_token =
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
let access_token = CloudAuthManager::load_access_token()?
.ok_or_else(|| crate::backend_error("PROFILE_LOCK_UNAVAILABLE"))?;
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}");
let response = client
@@ -104,24 +104,29 @@ impl ProfileLockManager {
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to acquire lock: {e}"))?;
.map_err(|e| {
log::warn!("Failed to acquire profile lock for {profile_id}: {e}");
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Lock acquisition failed ({status}): {body}"));
log::warn!("Profile lock acquisition for {profile_id} failed ({status}): {body}");
return Err(crate::backend_error("PROFILE_LOCK_UNAVAILABLE"));
}
let result: AcquireLockResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse lock response: {e}"))?;
let result: AcquireLockResponse = response.json().await.map_err(|e| {
log::warn!("Could not parse the profile lock response for {profile_id}: {e}");
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
})?;
if !result.success {
let email = result
.locked_by_email
.unwrap_or_else(|| "another device".to_string());
return Err(format!("Profile is in use by {email}"));
return Err(lock_conflict_error(
profile_id,
result.locked_by.as_deref(),
result.locked_by_email.as_deref(),
));
}
// Update local cache
@@ -274,15 +279,51 @@ impl ProfileLockManager {
}
}
/// Separator the backend puts between a user id and a non-desktop holder's
/// sub-identity. Mirrors `HOLDER_SEPARATOR` in donutbrowser-infra's
/// `profile-locks.service.ts`.
///
/// A remote VM session takes the lock under `<user id>:vm:<session id>` so it
/// contends with this desktop instead of silently sharing its lock. That makes
/// the holder string the one place a client can tell "a teammate has this open"
/// apart from "this is my own profile, running on the fleet" — two refusals that
/// need completely different words.
const VM_HOLDER_SEPARATOR: &str = ":vm:";
/// The `{"code":…}` for a lock this caller could not take.
fn lock_conflict_error(
profile_id: &str,
holder: Option<&str>,
holder_email: Option<&str>,
) -> String {
if holder.is_some_and(|id| id.contains(VM_HOLDER_SEPARATOR)) {
// The user's own remote session. Saying "in use by you@example.com" here,
// which is what the raw backend message did, reads as a bug.
log::info!("Profile {profile_id} is held by a remote session");
return crate::backend_error("PROFILE_RUNNING_REMOTELY");
}
match holder_email {
Some(email) if !email.is_empty() => serde_json::json!({
"code": "PROFILE_LOCKED_BY_MEMBER",
"params": { "email": email }
})
.to_string(),
_ => crate::backend_error("PROFILE_LOCKED_ELSEWHERE"),
}
}
/// Acquire profile lock if profile is sync-enabled and user has a paid subscription.
/// Returns whether a lock was actually taken, so a caller that unwinds a failed
/// launch releases only what it acquired. Releasing unconditionally would drop
/// a lock a REST handler up the stack still owns.
pub async fn acquire_team_lock_if_needed(
profile: &crate::profile::BrowserProfile,
) -> Result<(), String> {
) -> Result<bool, String> {
if !profile.is_sync_enabled() {
return Ok(());
return Ok(false);
}
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Ok(());
return Ok(false);
}
// Ensure lock manager is connected
@@ -294,13 +335,18 @@ pub async fn acquire_team_lock_if_needed(
.is_locked_by_another(&profile.id.to_string())
.await
{
if let Some(lock) = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await {
return Err(format!("Profile is in use by {}", lock.locked_by_email));
}
return Err("Profile is in use on another device".to_string());
let held = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await;
return Err(lock_conflict_error(
&profile.id.to_string(),
held.as_ref().map(|lock| lock.locked_by.as_str()),
held.as_ref().map(|lock| lock.locked_by_email.as_str()),
));
}
PROFILE_LOCK.acquire_lock(&profile.id.to_string()).await
PROFILE_LOCK
.acquire_lock(&profile.id.to_string())
.await
.map(|()| true)
}
/// Release profile lock if profile is sync-enabled and user has a paid subscription.
@@ -328,3 +374,39 @@ pub async fn get_team_locks() -> Result<Vec<ProfileLockInfo>, String> {
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, String> {
Ok(PROFILE_LOCK.get_lock_status(&profile_id).await)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_users_own_remote_session_is_not_reported_as_a_teammate() {
// The holder for a fleet session is `<user id>:vm:<session id>` and the row
// carries the OWNER's email, so the previous message read "Profile is in use
// by you@example.com" — the user's own address, about their own profile.
let err = lock_conflict_error(
"p1",
Some("11111111-2222-3333-4444-555555555555:vm:run-remote:p1:abc"),
Some("owner@example.com"),
);
assert_eq!(err, r#"{"code":"PROFILE_RUNNING_REMOTELY"}"#);
assert!(!err.contains("owner@example.com"));
}
#[test]
fn a_teammates_lock_names_them_through_a_translatable_code() {
let err = lock_conflict_error("p1", Some("other-user-id"), Some("mate@example.com"));
let json: serde_json::Value = serde_json::from_str(&err).expect("a code envelope");
assert_eq!(json["code"], "PROFILE_LOCKED_BY_MEMBER");
assert_eq!(json["params"]["email"], "mate@example.com");
}
#[test]
fn a_lock_with_no_identifiable_holder_still_produces_a_code() {
// Raw English here is what reaches a Russian user untranslated.
for holder in [None, Some("")] {
let err = lock_conflict_error("p1", holder, None);
assert_eq!(err, r#"{"code":"PROFILE_LOCKED_ELSEWHERE"}"#);
}
}
}
@@ -0,0 +1,709 @@
//! Enumerates extensions the user installed from inside the browser, by
//! walking the Chromium profile directory on disk.
//!
//! Depends only on `std`, `serde_json`, the sibling `rules` module, and the
//! shared profile-directory-name predicate, so the directory-layout handling —
//! the riskiest part of detection — stays testable without app state.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::rules::{
classify, lookup_message, manifest_str, message_placeholder_key, signal_labels,
signals_from_manifest, version_dir_sort_key, vpn_keyword_hit, DetectedVpnExtension,
};
/// Upper bound on extension directories walked per profile. A launch must not
/// stall behind a pathological profile; whatever was found is still reported,
/// flagged `partial`.
const MAX_EXTENSION_DIRS: usize = 300;
/// Manifests are a few KiB. Anything past this is not a manifest we can use.
const MAX_MANIFEST_BYTES: u64 = 512 * 1024;
/// Wall-clock ceiling for the whole scan. This runs on the launch path.
const SCAN_DEADLINE: Duration = Duration::from_millis(750);
/// Chromium preference files are larger than a manifest but still bounded; a
/// pathological one must not be parsed while the user waits for a browser.
const MAX_PREFERENCES_BYTES: u64 = 32 * 1024 * 1024;
/// Chromium profile directories to search inside a user-data dir.
///
/// Two layouts are real here: Donut launches Wayfern with only
/// `--user-data-dir`, so Chromium uses `Default/`; but an imported profile is
/// copied in as the profile directory itself, putting `Extensions/` at the
/// root. Checking only one layout misses every profile of the other kind.
fn candidate_profile_dirs(user_data_dir: &Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if user_data_dir.join("Preferences").exists() || user_data_dir.join("Extensions").is_dir() {
dirs.push(user_data_dir.to_path_buf());
}
if let Ok(entries) = std::fs::read_dir(user_data_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if crate::profile::clear_on_close::is_profile_dir_name(name)
|| path.join("Preferences").exists()
{
dirs.push(path);
}
}
}
dirs.sort();
dirs.dedup();
dirs
}
fn read_json_file(path: &Path, max_bytes: Option<u64>) -> Option<serde_json::Value> {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() {
return None;
}
if max_bytes.is_some_and(|cap| metadata.len() > cap) {
return None;
}
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
/// Chromium locale directory names are `[A-Za-z0-9_-]` (`en`, `en_GB`,
/// `zh_CN`). Anything else in a manifest we did not write is untrusted input
/// being joined into a filesystem path, so it is refused rather than sanitized
/// — this runs on the launch path against extensions the user may have
/// sideloaded.
fn is_safe_locale_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 32
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn resolve_dir_i18n(
version_dir: &Path,
manifest: &serde_json::Value,
value: &str,
) -> Option<String> {
let key = message_placeholder_key(value)?;
let default_locale = manifest.get("default_locale")?.as_str()?;
if !is_safe_locale_name(default_locale) {
log::warn!("Ignoring extension with a suspicious default_locale: {default_locale:?}");
return None;
}
let messages = read_json_file(
&version_dir
.join("_locales")
.join(default_locale)
.join("messages.json"),
Some(MAX_MANIFEST_BYTES),
)?;
lookup_message(&messages, &key)
}
/// What the profile's preference files say about installed extensions.
///
/// Read-only on purpose: `Secure Preferences` is MAC-protected, and rewriting
/// it invalidates the signature, which disables every extension in the profile.
#[derive(Default)]
struct PreferenceExtensions {
/// Explicitly disabled. A disabled extension cannot touch the proxy, so
/// warning about it would be a false alarm.
disabled: HashSet<String>,
/// Unpacked ("Load unpacked" / developer mode) extensions, which live
/// OUTSIDE `Extensions/` and are therefore invisible to the directory walk.
/// Sideloading is exactly how someone gets a VPN extension in without the
/// Web Store, so missing these would leave the obvious hole open.
unpacked: Vec<(String, PathBuf)>,
}
fn preference_extensions(profile_dir: &Path) -> PreferenceExtensions {
let mut out = PreferenceExtensions::default();
for file in ["Secure Preferences", "Preferences"] {
// Larger than a manifest, but still capped: this is parsed while the user
// waits for a browser to start.
let Some(prefs) = read_json_file(&profile_dir.join(file), Some(MAX_PREFERENCES_BYTES)) else {
continue;
};
let Some(settings) = prefs
.get("extensions")
.and_then(|e| e.get("settings"))
.and_then(|s| s.as_object())
else {
continue;
};
for (id, entry) in settings {
// Chromium's Extension::State: 0 = disabled.
if entry.get("state").and_then(serde_json::Value::as_i64) == Some(0) {
out.disabled.insert(id.clone());
continue;
}
// A packed extension's `path` is relative to Extensions/; an unpacked
// one records an absolute path elsewhere on disk.
if let Some(path) = entry.get("path").and_then(|p| p.as_str()) {
let path = PathBuf::from(path);
if path.is_absolute() {
out.unpacked.push((id.clone(), path));
}
}
}
}
out
}
/// Walk a user-data dir for VPN/proxy extensions.
///
/// Returns false when the walk was cut short by a cap or the deadline, so the
/// caller can report the scan as incomplete rather than clean.
pub(super) fn scan_browser_extensions(
user_data_dir: &Path,
out: &mut Vec<DetectedVpnExtension>,
started: Instant,
) -> bool {
let mut walked = 0usize;
for profile_dir in candidate_profile_dirs(user_data_dir) {
// Read preferences first: unpacked extensions live outside Extensions/, so
// a profile that has only sideloaded ones has no Extensions/ dir at all and
// must not be skipped before they are considered.
if started.elapsed() > SCAN_DEADLINE {
return false;
}
let prefs = preference_extensions(&profile_dir);
let disabled = &prefs.disabled;
for (crx_id, unpacked_dir) in &prefs.unpacked {
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
return false;
}
walked += 1;
if let Some(found) = detect_in_version_dir(crx_id, unpacked_dir) {
out.push(found);
}
}
let Ok(entries) = std::fs::read_dir(profile_dir.join("Extensions")) else {
continue;
};
for entry in entries.flatten() {
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
return false;
}
walked += 1;
let ext_dir = entry.path();
if !ext_dir.is_dir() {
continue;
}
let Some(crx_id) = ext_dir
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
else {
continue;
};
if disabled.contains(&crx_id) {
continue;
}
// Several versions can coexist on disk; Chromium runs the highest.
let Ok(version_entries) = std::fs::read_dir(&ext_dir) else {
continue;
};
let mut versions: Vec<PathBuf> = version_entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
versions.sort_by_key(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(version_dir_sort_key)
.unwrap_or_default()
});
let Some(version_dir) = versions.last() else {
continue;
};
if let Some(found) = detect_in_version_dir(&crx_id, version_dir) {
out.push(found);
}
}
}
true
}
/// Classify the extension whose unpacked files live in `version_dir`.
/// Shared by the packed walk and the unpacked (developer-mode) entries.
fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpnExtension> {
let manifest = read_json_file(&version_dir.join("manifest.json"), Some(MAX_MANIFEST_BYTES))?;
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| crx_id.to_string());
let name = resolve_dir_i18n(version_dir, &manifest, &raw_name).unwrap_or_else(|| {
if message_placeholder_key(&raw_name).is_some() {
crx_id.to_string()
} else {
raw_name.clone()
}
});
let description = manifest_str(&manifest, "description").and_then(|d| {
resolve_dir_i18n(version_dir, &manifest, &d).or(if message_placeholder_key(&d).is_some() {
None
} else {
Some(d)
})
});
let signals = signals_from_manifest(&manifest);
let keyword = vpn_keyword_hit(&name, description.as_deref());
let confidence = classify(Some(crx_id), &signals, keyword)?;
Some(DetectedVpnExtension {
key: format!("crx:{crx_id}"),
name,
version: manifest_str(&manifest, "version"),
source: "browser".to_string(),
confidence: confidence.to_string(),
proxy_control: signals.proxy_permission,
signals: signal_labels(Some(crx_id), &signals, keyword),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
/// Build a Chromium `Preferences` blob for one extension.
///
/// Serialized rather than string-interpolated on purpose: a Windows path is
/// `C:\Users\...`, and pasting it into a JSON string literal produces invalid
/// escape sequences, so the file silently fails to parse and every assertion
/// about it passes for the wrong reason.
fn preferences_json(crx_id: &str, state: i64, path: &Path) -> String {
serde_json::json!({
"extensions": {
"settings": {
crx_id: { "state": state, "path": path.to_string_lossy() }
}
}
})
.to_string()
}
const VPN_MANIFEST: &str = r#"{"name":"Turbo VPN","version":"2.1.0","permissions":["proxy"]}"#;
const CRX_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
#[test]
fn candidate_dirs_accepts_the_default_layout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("Default").join("Preferences"), "{}");
assert_eq!(candidate_profile_dirs(root), vec![root.join("Default")]);
}
#[test]
fn candidate_dirs_accepts_the_imported_root_layout() {
// profile_importer copies a Chromium profile dir in as the root, so
// Preferences and Extensions/ sit at the top level.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("Preferences"), "{}");
assert_eq!(candidate_profile_dirs(root), vec![root.to_path_buf()]);
}
#[test]
fn candidate_dirs_finds_named_profile_dirs_without_preferences() {
// Chromium writes Preferences lazily, so a populated Default/ can lack it.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("Profile 2").join("Extensions")).unwrap();
assert_eq!(candidate_profile_dirs(root), vec![root.join("Profile 2")]);
}
#[test]
fn scan_finds_a_vpn_extension_in_the_default_layout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
assert_eq!(out[0].name, "Turbo VPN");
assert_eq!(out[0].confidence, "confirmed");
assert_eq!(out[0].source, "browser");
}
#[test]
fn scan_finds_a_vpn_extension_in_the_imported_root_layout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("Preferences"), "{}");
write(
&root
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
}
#[test]
fn scan_reads_the_highest_version_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let ext = root.join("Default").join("Extensions").join(CRX_ID);
// Lexicographically "1.9.0_0" > "1.10.0_0"; numerically it is not.
write(
&ext.join("1.9.0_0").join("manifest.json"),
r#"{"name":"Old","version":"1.9.0","permissions":["storage"]}"#,
);
write(
&ext.join("1.10.0_0").join("manifest.json"),
r#"{"name":"New VPN","version":"1.10.0","permissions":["proxy"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].version.as_deref(), Some("1.10.0"));
}
#[test]
fn scan_skips_extensions_chromium_has_disabled() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let profile = root.join("Default");
write(
&profile
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
write(
&profile.join("Secure Preferences"),
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":0}}}}}}}}"#),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(
out.is_empty(),
"a disabled extension cannot touch the proxy"
);
}
#[test]
fn scan_keeps_enabled_extensions_listed_in_preferences() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let profile = root.join("Default");
write(
&profile
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
write(
&profile.join("Secure Preferences"),
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1}}}}}}}}"#),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
}
#[test]
fn scan_resolves_a_localized_extension_name() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let version_dir = root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0");
write(
&version_dir.join("manifest.json"),
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"en","permissions":["proxy"]}"#,
);
write(
&version_dir
.join("_locales")
.join("en")
.join("messages.json"),
r#"{"appName":{"message":"Nord VPN"}}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out[0].name, "Nord VPN");
}
#[test]
fn scan_falls_back_to_the_crx_id_when_a_placeholder_cannot_be_resolved() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
r#"{"name":"__MSG_appName__","version":"2.1.0","permissions":["proxy"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out[0].name, CRX_ID, "never show a raw __MSG_ placeholder");
}
#[test]
fn scan_reports_a_proxy_holding_download_manager_as_a_capability() {
// End-to-end shape of the false positive that prompted the audit: the
// extension must still be surfaced (it really can change the proxy) but
// never as a VPN.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join("ngpampappnmepgilojfohadhhmbhlaek")
.join("6.43.1_0")
.join("manifest.json"),
r#"{"name":"IDM Integration Module","version":"6.43.1","description":"Download files with Internet Download Manager","permissions":["downloads","storage","proxy","nativeMessaging"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].confidence, "capability");
assert!(out[0].proxy_control);
}
#[test]
fn scan_confirms_a_known_vpn_whose_name_gives_nothing_away() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join("nlbejmccbhkncgokjcmghpfloaajcffj")
.join("10.0.0_0")
.join("manifest.json"),
r#"{"name":"Hotspot Shield","version":"10.0.0","permissions":["proxy"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].confidence, "confirmed");
}
#[test]
fn scan_ignores_an_ordinary_extension() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("1.0.0_0")
.join("manifest.json"),
r#"{"name":"Dark Reader","version":"1.0.0","permissions":["storage","activeTab"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(out.is_empty());
}
#[test]
fn scan_reports_incomplete_when_the_deadline_has_passed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
let mut out = Vec::new();
// A start time already past the deadline stands in for a slow disk.
let expired = Instant::now() - SCAN_DEADLINE - Duration::from_millis(10);
assert!(!scan_browser_extensions(root, &mut out, expired));
}
#[test]
fn scan_survives_a_malformed_manifest() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
"{ not json",
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(out.is_empty());
}
#[test]
fn preferences_json_escapes_windows_style_paths() {
// The Windows CI failure this guards: a raw `C:\Users\...` pasted into a
// JSON string literal is invalid (`\U` is not an escape), so Preferences
// failed to parse, `preference_extensions` returned nothing, and the
// unpacked extension silently vanished — on Linux the same test passed
// because POSIX paths contain no backslashes.
let json = preferences_json(CRX_ID, 1, Path::new(r"C:\Users\runner\ext\my-vpn"));
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("Preferences must be valid JSON on every platform");
assert_eq!(
parsed["extensions"]["settings"][CRX_ID]["path"],
r"C:\Users\runner\ext\my-vpn"
);
}
#[test]
fn scan_finds_an_unpacked_developer_mode_extension() {
// Sideloading via "Load unpacked" is exactly how a VPN extension gets in
// without the Web Store, and those files live outside Extensions/.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let unpacked = root.join("somewhere-else").join("my-vpn");
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
write(
&root.join("Default").join("Preferences"),
&preferences_json(CRX_ID, 1, &unpacked),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1, "unpacked extensions must not be invisible");
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
assert_eq!(out[0].confidence, "confirmed");
}
#[test]
fn scan_ignores_a_disabled_unpacked_extension() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let unpacked = root.join("somewhere-else").join("my-vpn");
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
write(
&root.join("Default").join("Preferences"),
&preferences_json(CRX_ID, 0, &unpacked),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(out.is_empty());
}
#[test]
fn packed_relative_paths_are_not_treated_as_unpacked() {
// A packed extension records a path relative to Extensions/; following it
// as if absolute would read the wrong place (or nothing).
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root.join("Default").join("Preferences"),
&preferences_json(CRX_ID, 1, Path::new(&format!("{CRX_ID}/2.1.0_0"))),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(out.is_empty());
}
#[test]
fn a_traversing_default_locale_is_refused() {
// `default_locale` comes from a manifest we did not write. Joined naively
// it reads any file the user can read, on the launch path.
assert!(!is_safe_locale_name("../../../../etc"));
assert!(!is_safe_locale_name("..\\..\\windows"));
assert!(!is_safe_locale_name("/etc/passwd"));
assert!(!is_safe_locale_name(""));
assert!(is_safe_locale_name("en"));
assert!(is_safe_locale_name("en_GB"));
assert!(is_safe_locale_name("zh-CN"));
}
#[test]
fn a_localized_name_with_a_traversing_locale_is_not_resolved() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let version_dir = root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0");
write(
&version_dir.join("manifest.json"),
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"../../../../etc","permissions":["proxy"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
// Still detected (the proxy permission is what matters), but the name
// falls back rather than the traversal being followed.
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, CRX_ID);
}
#[test]
fn scan_tolerates_a_missing_user_data_dir() {
let tmp = tempfile::tempdir().unwrap();
let mut out = Vec::new();
assert!(scan_browser_extensions(
&tmp.path().join("nope"),
&mut out,
Instant::now()
));
assert!(out.is_empty());
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Detects VPN/proxy browser extensions present in a profile.
//!
//! An extension holding Chromium's `proxy` permission can override the proxy
//! Donut passes on the command line, so the browser's real exit stops being the
//! one Donut measured and generated the fingerprint against. That produces
//! exactly the geo/timezone/language mismatch the fingerprint exists to avoid,
//! except Donut cannot observe it from the outside — hence a launch-time
//! warning rather than a measurement.
//!
//! That permission is a capability, not an identity. Chromium exposes no
//! read-only variant of it, so a download manager replicating the browser's
//! route for its own transfers declares exactly what a VPN hijacking it
//! declares. The two are reported as different things — see `rules::classify`.
//!
//! Two sources, deliberately both: Donut-managed extensions live in the app's
//! own store and are handed to Chromium via `--load-extension` from *outside*
//! the profile directory, while extensions the user installed from the Web
//! Store live *inside* it. Neither set appears in the other.
mod browser_scan;
mod rules;
// `message_placeholder_key`/`lookup_message` are shared with
// `extension_manager`, which resolves the same placeholders out of a zip.
use rules::{classify, manifest_str, signal_labels, signals_from_manifest, vpn_keyword_hit};
pub use rules::{lookup_message, message_placeholder_key, DetectedVpnExtension};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::time::Instant;
use crate::profile::types::BrowserProfile;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExtensionScan {
pub extensions: Vec<DetectedVpnExtension>,
/// `scanned` | `partial` | `encrypted` | `ephemeral` | `missing`.
///
/// Reported honestly so the dialog can say the scan was incomplete rather
/// than implying a clean profile it never managed to read.
pub scan_state: String,
}
/// Donut-managed extensions, reached through the profile's extension group.
///
/// Read live from the stored archive rather than from the metadata cached on
/// `Extension`, so replacing an extension's file cannot leave a stale verdict
/// behind. N is the group size — typically a handful.
fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExtension>) {
let Some(group_id) = &profile.extension_group_id else {
return;
};
let Ok(manager) = crate::extension_manager::EXTENSION_MANAGER.lock() else {
log::warn!("VPN extension scan: extension manager lock poisoned, skipping managed extensions");
return;
};
let Ok(group) = manager.get_group(group_id) else {
return;
};
for ext_id in &group.extension_ids {
let Ok(ext) = manager.get_extension(ext_id) else {
continue;
};
let path = manager.get_file_dir_public(ext_id).join(&ext.file_name);
let Ok(data) = std::fs::read(&path) else {
continue;
};
let Some(manifest) =
crate::extension_manager::read_manifest_from_archive(&data, &ext.file_type)
else {
continue;
};
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| ext.name.clone());
let name =
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &raw_name)
.unwrap_or_else(|| {
// An unresolvable placeholder is not a name — fall back to the one
// the extension carries in Donut.
if message_placeholder_key(&raw_name).is_some() {
ext.name.clone()
} else {
raw_name.clone()
}
});
let description = manifest_str(&manifest, "description").and_then(|d| {
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &d).or(
if message_placeholder_key(&d).is_some() {
None
} else {
Some(d)
},
)
});
let signals = signals_from_manifest(&manifest);
let keyword = vpn_keyword_hit(&name, description.as_deref());
// A Donut-managed extension is stored under Donut's own uuid, not the Web
// Store id the known-VPN list is keyed on, so it is classified on what its
// manifest says about itself.
let Some(confidence) = classify(None, &signals, keyword) else {
continue;
};
out.push(DetectedVpnExtension {
key: format!("donut:{ext_id}"),
name,
version: manifest_str(&manifest, "version").or_else(|| ext.version.clone()),
source: "donut".to_string(),
confidence: confidence.to_string(),
proxy_control: signals.proxy_permission,
signals: signal_labels(None, &signals, keyword),
});
}
}
/// Scan a profile for VPN/proxy extensions from both sources.
///
/// Never fails: an unreadable profile reports whatever it could see plus a
/// `scan_state` explaining why the picture is incomplete.
pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
let started = Instant::now();
let mut extensions = Vec::new();
scan_donut_extensions(profile, &mut extensions);
let profiles_dir = crate::app_dirs::profiles_dir();
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
// `get_effective_profile_path` only returns the decrypted RAM copy while the
// profile is unlocked; locked, it falls back to the on-disk directory, which
// exists but is ciphertext. Walking that finds nothing — so the state has to
// be decided on whether we actually got a readable copy, not on the path
// existing, or a locked profile reports as verified-clean.
let has_plaintext_dir = !(profile.password_protected || profile.ephemeral)
|| crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some();
let scan_state = if !has_plaintext_dir {
if profile.password_protected {
"encrypted"
} else {
"ephemeral"
}
} else if !user_data_dir.is_dir() {
// Never launched, so there is no profile directory to inspect yet.
"missing"
} else if browser_scan::scan_browser_extensions(&user_data_dir, &mut extensions, started) {
"scanned"
} else {
"partial"
};
// Collapse only exact duplicates of the same extension. `key` is the real
// identity (`donut:<uuid>` / `crx:<id>`); name+version is not, and two
// distinct extensions sharing a display name would silently fold into one,
// hiding a real detection behind an unrelated namesake.
let mut seen = HashSet::new();
extensions.retain(|e| seen.insert(e.key.clone()));
ExtensionScan {
extensions,
scan_state: scan_state.to_string(),
}
}
/// True when at least one extension holds the `proxy` permission outright, so
/// it can redirect the browser's traffic without asking for anything further.
///
/// Informational: it tells the user an exit measurement may describe a route
/// the browser will not take. It deliberately does not relax the gate — a
/// measurement that might be wrong is a reason for more scrutiny, not less,
/// and this signal is true for every download manager on the machine.
pub fn has_proxy_control(scan: &ExtensionScan) -> bool {
scan.extensions.iter().any(|e| e.proxy_control)
}
+529
View File
@@ -0,0 +1,529 @@
//! Pure classification rules for VPN/proxy extension detection.
//!
//! Deliberately free of crate-internal dependencies (`std` + `serde_json`
//! only): these rules are the heart of the feature and the part most worth
//! testing in isolation, so nothing here may reach for app state, the
//! filesystem, or the network. Enumerating the two extension sources and
//! reading them off disk lives in the parent module.
use serde::{Deserialize, Serialize};
/// Chrome Web Store ids of extensions whose whole purpose is routing the
/// browser somewhere else. Sorted, so membership is a binary search.
///
/// This list is what lets a VPN with an unrevealing name — "Hotspot Shield"
/// says nothing about what it does — be named as one instead of appearing as
/// an anonymous holder of the proxy permission. Every id was verified by
/// downloading the extension and reading its manifest; a wrong id is worse
/// than a missing one, because a stale list only ever loses recall while a
/// wrong one accuses the wrong extension.
const KNOWN_VPN_EXTENSION_IDS: &[&str] = &[
"adlpodnneegcnbophopdmhedicjbcgco", // Troywell VPN
"ailoabdmgclmfmhdagmlohpjlbpffblp", // Surfshark
"akcocjjpkmlniicdeemdceeajlmoabhg", // 1VPN
"apbcbecdpjefgklcokinpapmmdekecah", // Ninja VPN
"bihmplhobchoageeokmgbdihknkjbknd", // Touch VPN (delisted 2025, still installed in old profiles)
"blapeiihifiknfmceddkceklnpopgclm", // Proxy Switcher Pro
"bnlofglpdlboacepdieejiecfbfpmhlb", // Turbo VPN
"dookpfaalaaappcdneeahomimbllocnb", // FoxyProxy Basic
"eppiocemhmnlbhjplcgkofciiegomcon", // Urban VPN
"fcfhplploccackoneaefokcmbjfbkenj", // 1clickVPN
"fdcgdnkidjaadafnichfpabhfomcebme", // ZenMate (delisted 2025)
"ffbkglfijbcbgblgflchnbphjdllaogb", // CyberGhost
"fgddmllnllkalaagkghckoinaemmogpe", // ExpressVPN
"fjoaledfpmneenckfbpdfhkmimnjocfa", // NordVPN
"gcknhkkoolaabfmlnjonogaaifnjlfnp", // FoxyProxy
"gdpehpfhegefkjelaifkdbppjbhilaom", // Proxy-Cheap Proxy Manager
"gjakohbhfclfjmhhlenfdkldieofkpjl", // IPRoyal Proxy Manager
"gjknjjomckknofjidppipffbpoekiipm", // Betternet
"gkojfkhlekighikafcpjkiklfbnlmeio", // Hola VPN
"hnmpcagpplmpfojmgmnngilcnanddlhb", // Windscribe
"jaoafpkngncfpfggjefnekilbkcpjdgp", // uVPN
"jedieiamjmoflcknjdjhpieklepfglin", // FastestVPN
"jpadbaildllggkcgibilkeacpcodailn", // Planet VPN lite
"jplgfhpmjnbigmhklmmbgecoobifkmpa", // Proton VPN
"jplnlifepflhkbkgonidnobkakhmpnmh", // Private Internet Access
"kgepmkaldicdcljckhamnhkigddnbcbd", // PACify Proxy Manager
"kpiecbcckbofpmkkkdibbllpinceiihk", // DotVPN
"majdfhpaihoncoakbjgbdhglocklcgno", // VeePN
"nbcojefnccbanplpoffopkoepjmhgdgh", // Hoxx VPN
"nlbejmccbhkncgokjcmghpfloaajcffj", // Hotspot Shield
"ohjocgmpmlfahafbipehkhbaacoemojp", // hide.me Proxy
"omdakjcmkglenbhjadbccaookpfjihpa", // TunnelBear
"omghfjlpggmjjaagoclmmobgdodcjboh", // Browsec
"onnfghpihccifgojkpnnncpagjcdbjod", // Proxy Switcher and Manager
"oofgbpoabipfcfjapgnbbjjaenockbdp", // SetupVPN
"padekgcemlokbadohgkifijomclgjgif", // Proxy SwitchyOmega
"pphgdbgldlmicfdkhondlafkiomnelnk", // 1ClickVPN Proxy
];
/// Terms specific enough to name a VPN wherever they appear, including in a
/// 132-character manifest description.
const STRONG_KEYWORDS: &[&str] = &["vpn", "wireguard", "shadowsocks", "openvpn"];
/// Terms that only mean "VPN" in a product's *name*. In a description they are
/// ordinary English — "no proxy setup required", "carpal tunnel", "unblock
/// right click" — and matching them there is where the noise comes from.
const NAME_ONLY_KEYWORDS: &[&str] = &["proxy", "unblock"];
/// Matched as whole tokens rather than substrings, and in the name only. Too
/// short to be safe inside other words ("tussocks", "tunnelling").
const NAME_TOKEN_KEYWORDS: &[&str] = &["socks", "socks5", "tunnel"];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DetectedVpnExtension {
/// Stable acknowledgement identity: `donut:<uuid>` or `crx:<32-char-id>`.
pub key: String,
pub name: String,
pub version: Option<String>,
/// `"donut"` (managed by Donut) or `"browser"` (installed inside the profile).
pub source: String,
/// `"confirmed"` and `"likely"` are claims that this IS a VPN/proxy tool.
/// `"capability"` claims only that it *could* change the proxy.
pub confidence: String,
/// Whether the manifest holds Chromium's `proxy` permission outright, so the
/// extension can call `chrome.proxy.settings.set` without asking again.
/// Separate from `confidence`: a download manager reading the browser's
/// proxy declares the identical permission as a VPN hijacking it.
pub proxy_control: bool,
/// Why it matched, for the dialog's detail line.
pub signals: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ManifestSignals {
pub proxy_permission: bool,
pub optional_proxy_permission: bool,
pub declarative_net_request: bool,
pub web_request_blocking: bool,
pub broad_host_permissions: bool,
}
fn string_list<'a>(manifest: &'a serde_json::Value, key: &str) -> Vec<&'a str> {
manifest
.get(key)
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default()
}
fn is_broad_host(pattern: &str) -> bool {
matches!(pattern, "<all_urls>" | "*://*/*")
}
pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
let permissions = string_list(manifest, "permissions");
let optional_permissions = string_list(manifest, "optional_permissions");
let host_permissions = string_list(manifest, "host_permissions");
let optional_host_permissions = string_list(manifest, "optional_host_permissions");
let has = |list: &[&str], name: &str| list.contains(&name);
// MV2 keeps host patterns inside `permissions`; MV3 splits them into
// `host_permissions`. Look in both so one manifest version isn't silently
// under-detected.
let all_hosts: Vec<&str> = permissions
.iter()
.chain(host_permissions.iter())
.chain(optional_host_permissions.iter())
.copied()
.collect();
let broad = all_hosts.iter().any(|p| is_broad_host(p))
|| (all_hosts.contains(&"http://*/*") && all_hosts.contains(&"https://*/*"));
ManifestSignals {
proxy_permission: has(&permissions, "proxy"),
optional_proxy_permission: has(&optional_permissions, "proxy"),
declarative_net_request: has(&permissions, "declarativeNetRequest")
|| has(&permissions, "declarativeNetRequestWithHostAccess"),
web_request_blocking: has(&permissions, "webRequest")
&& has(&permissions, "webRequestBlocking"),
broad_host_permissions: broad,
}
}
/// True when this is the id of an extension known to route browser traffic.
pub fn is_known_vpn_extension(extension_id: &str) -> bool {
KNOWN_VPN_EXTENSION_IDS.binary_search(&extension_id).is_ok()
}
fn has_token(haystack: &str, tokens: &[&str]) -> bool {
haystack
.split(|c: char| !c.is_alphanumeric())
.any(|token| tokens.contains(&token))
}
/// Does the extension describe itself as a VPN or proxy tool?
///
/// The name is weighted far more heavily than the description, because that is
/// where the evidence actually lives: a VPN vendor puts "VPN" in the name — it
/// is how the store surfaces them — while a description is 132 characters of
/// ordinary prose in which "proxy", "tunnel" and "unblock" are all innocent.
/// Matching those three against descriptions is what flags carpal-tunnel
/// reminders, right-click unblockers, and tools whose pitch is that they need
/// *no* proxy setup.
pub fn vpn_keyword_hit(name: &str, description: Option<&str>) -> bool {
let name = name.to_lowercase();
if STRONG_KEYWORDS.iter().any(|k| name.contains(k))
|| NAME_ONLY_KEYWORDS.iter().any(|k| name.contains(k))
|| has_token(&name, NAME_TOKEN_KEYWORDS)
{
return true;
}
description
.map(str::to_lowercase)
.is_some_and(|d| STRONG_KEYWORDS.iter().any(|k| d.contains(k)))
}
/// Classify an extension from its id, manifest signals and self-description.
///
/// Two different questions are answered here, and fusing them is what made an
/// ordinary download manager get reported as a VPN. Chromium has no read-only
/// variant of the `proxy` permission: `chrome.proxy.settings.get()` and
/// `.set()` sit behind the same manifest string, so an extension replicating
/// the browser's proxy for its own transfers declares exactly what a VPN
/// hijacking it declares. The permission therefore proves a *capability* and
/// nothing more; naming something a VPN needs separate evidence — a known id,
/// or the extension saying so itself.
///
/// The request-blocking tier's keyword requirement is not optional either:
/// `declarativeNetRequest` plus `<all_urls>` describes every content blocker in
/// the ecosystem, so without it the warning fires on uBlock Origin — which
/// would teach users to dismiss the dialog on sight, destroying the value of
/// the mismatch block that shares it.
///
/// An `optional_permissions` entry the user has never granted is deliberately
/// not a capability at all: the extension cannot call `chrome.proxy` until it
/// asks and is allowed.
pub fn classify(
extension_id: Option<&str>,
signals: &ManifestSignals,
keyword: bool,
) -> Option<&'static str> {
if extension_id.is_some_and(is_known_vpn_extension) {
return Some("confirmed");
}
if keyword {
if signals.proxy_permission {
return Some("confirmed");
}
if signals.optional_proxy_permission
|| ((signals.declarative_net_request || signals.web_request_blocking)
&& signals.broad_host_permissions)
{
return Some("likely");
}
}
if signals.proxy_permission {
return Some("capability");
}
None
}
pub fn signal_labels(
extension_id: Option<&str>,
signals: &ManifestSignals,
keyword: bool,
) -> Vec<String> {
let mut out = Vec::new();
if extension_id.is_some_and(is_known_vpn_extension) {
out.push("knownVpnExtension".to_string());
}
if signals.proxy_permission {
out.push("permissions:proxy".to_string());
}
if signals.optional_proxy_permission {
out.push("optionalPermissions:proxy".to_string());
}
if signals.declarative_net_request {
out.push("declarativeNetRequest".to_string());
}
if signals.web_request_blocking {
out.push("webRequestBlocking".to_string());
}
if signals.broad_host_permissions {
out.push("broadHostPermissions".to_string());
}
if keyword {
out.push("keyword".to_string());
}
out
}
/// `__MSG_someKey__` -> `someKey`.
pub fn message_placeholder_key(value: &str) -> Option<String> {
value
.strip_prefix("__MSG_")
.and_then(|rest| rest.strip_suffix("__"))
.map(str::to_string)
}
/// Chromium's `messages.json` shape: `{ "key": { "message": "..." } }`, with
/// keys compared case-insensitively.
pub fn lookup_message(messages: &serde_json::Value, key: &str) -> Option<String> {
let obj = messages.as_object()?;
obj
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.and_then(|(_, v)| v.get("message"))
.and_then(|v| v.as_str())
.map(str::to_string)
}
pub fn manifest_str(manifest: &serde_json::Value, key: &str) -> Option<String> {
manifest
.get(key)
.and_then(|v| v.as_str())
.map(str::to_string)
}
/// Sort key for an extension version directory (`1.10.0_0`), compared
/// numerically so `1.10.0` sorts above `1.9.0` where a lexicographic compare
/// would put it below.
pub fn version_dir_sort_key(name: &str) -> Vec<u64> {
name
.split(['.', '_'])
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn signals_of(manifest: serde_json::Value) -> ManifestSignals {
signals_from_manifest(&manifest)
}
fn classify_named(
manifest: serde_json::Value,
name: &str,
description: Option<&str>,
) -> Option<&'static str> {
let s = signals_of(manifest);
classify(None, &s, vpn_keyword_hit(name, description))
}
#[test]
fn classify_confirms_a_self_described_vpn_holding_the_proxy_permission() {
let s = signals_of(json!({ "permissions": ["proxy", "storage"] }));
assert!(s.proxy_permission);
assert_eq!(
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
Some("confirmed")
);
}
#[test]
fn classify_confirms_proxy_permission_in_mv2() {
// `proxy` is an API permission, so MV3's host_permissions split does not
// move it — the same key works for both manifest versions.
let s = signals_of(json!({
"manifest_version": 2,
"permissions": ["proxy", "<all_urls>", "webRequest"]
}));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Hoxx VPN Proxy", None)),
Some("confirmed")
);
}
#[test]
fn a_download_manager_is_reported_as_a_capability_never_as_a_vpn() {
// The bug this whole split exists for. IDM Integration Module declares
// `proxy` so the desktop binary can replicate the browser's route for a
// handed-off download, and says nothing about VPNs anywhere. Verified
// against the real published manifest.
let verdict = classify_named(
json!({
"permissions": [
"scripting", "tabs", "cookies", "contextMenus", "webNavigation",
"webRequest", "declarativeNetRequest", "downloads", "downloads.shelf",
"downloads.ui", "management", "storage", "proxy", "nativeMessaging"
]
}),
"IDM Integration Module",
Some("Download files with Internet Download Manager"),
);
assert_eq!(verdict, Some("capability"));
}
#[test]
fn a_known_vpn_is_confirmed_from_its_id_alone() {
// Hotspot Shield's name contains no keyword at all, so without the id list
// the biggest VPN in the store would be indistinguishable from a download
// manager.
let s = signals_of(json!({ "permissions": ["proxy"] }));
let id = "nlbejmccbhkncgokjcmghpfloaajcffj";
assert_eq!(
classify(Some(id), &s, vpn_keyword_hit("Hotspot Shield", None)),
Some("confirmed")
);
assert!(signal_labels(Some(id), &s, false).contains(&"knownVpnExtension".to_string()));
}
#[test]
fn the_known_vpn_id_list_is_sorted_and_well_formed() {
// Membership is a binary search, so an unsorted entry is silently missed.
assert!(KNOWN_VPN_EXTENSION_IDS.windows(2).all(|w| w[0] < w[1]));
for id in KNOWN_VPN_EXTENSION_IDS {
assert_eq!(id.len(), 32, "{id} is not a Chrome extension id");
assert!(
id.bytes().all(|b| (b'a'..=b'p').contains(&b)),
"{id} is not a Chrome extension id"
);
}
}
#[test]
fn classify_ignores_content_blocker() {
// The regression guard: a content blocker declares exactly these and is
// not a VPN. Firing here would train users to dismiss the dialog.
let s = signals_of(json!({
"permissions": ["declarativeNetRequest"],
"host_permissions": ["<all_urls>"]
}));
assert!(s.declarative_net_request && s.broad_host_permissions);
assert_eq!(
classify(None, &s, vpn_keyword_hit("uBlock Origin", None)),
None
);
}
#[test]
fn classify_likely_on_dnr_plus_keyword() {
let s = signals_of(json!({
"permissions": ["declarativeNetRequest"],
"host_permissions": ["<all_urls>"]
}));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Free VPN Proxy", None)),
Some("likely")
);
}
#[test]
fn classify_likely_on_optional_proxy_plus_keyword() {
// Optional and ungranted is not a capability, so it only matters when the
// extension also says what it is.
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Some VPN", None)),
Some("likely")
);
assert_eq!(
classify(None, &s, vpn_keyword_hit("Request Interceptor", None)),
None
);
}
#[test]
fn classify_ignores_keyword_only() {
// A name alone proves nothing; without a capability signal this is noise.
let s = signals_of(json!({ "permissions": ["storage"] }));
assert_eq!(
classify(None, &s, vpn_keyword_hit("VPN Deals Finder", None)),
None
);
}
#[test]
fn classify_requires_broad_hosts_for_the_blocking_tier() {
let s = signals_of(json!({
"permissions": ["declarativeNetRequest"],
"host_permissions": ["https://example.com/*"]
}));
assert_eq!(classify(None, &s, vpn_keyword_hit("Some VPN", None)), None);
}
#[test]
fn broad_hosts_detected_from_split_http_and_https() {
let s = signals_of(json!({
"permissions": ["webRequest", "webRequestBlocking"],
"host_permissions": ["http://*/*", "https://*/*"]
}));
assert!(s.broad_host_permissions);
assert!(s.web_request_blocking);
}
#[test]
fn broad_hosts_detected_from_mv2_permissions_array() {
// MV2 puts host patterns in `permissions`; the split-out key is absent.
let s = signals_of(json!({
"manifest_version": 2,
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"]
}));
assert!(s.broad_host_permissions);
assert_eq!(
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
Some("likely")
);
}
#[test]
fn keyword_matching_reads_the_name_broadly_and_the_description_narrowly() {
assert!(vpn_keyword_hit("TouchVPN", None));
assert!(vpn_keyword_hit("Unblock Sites", None));
assert!(vpn_keyword_hit("Shadowsocks Client", None));
// Whole-token terms must not match inside longer words. "socks" in a name
// is the protocol often enough to keep; "tussocks" and "tunnelling" are
// exactly why it cannot be a substring.
assert!(vpn_keyword_hit("SOCKS5 Configurator", None));
assert!(!vpn_keyword_hit("Tussocks Field Guide", None));
assert!(!vpn_keyword_hit("Tunnelling Contractors CRM", None));
// A description says "VPN" only when it means one...
assert!(vpn_keyword_hit(
"Anything",
Some("a free VPN for your browser")
));
// ...but these three are ordinary English and must not promote anything.
assert!(!vpn_keyword_hit(
"Requestly",
Some("Modify HTTP requests, no proxy setup required")
));
assert!(!vpn_keyword_hit(
"Stretch Reminder",
Some("Avoid carpal tunnel syndrome while you work")
));
assert!(!vpn_keyword_hit(
"Absolute Right Click",
Some("Unblock right click and text selection on any site")
));
}
#[test]
fn message_placeholder_round_trip() {
assert_eq!(
message_placeholder_key("__MSG_appName__").as_deref(),
Some("appName")
);
assert_eq!(message_placeholder_key("Plain Name"), None);
let messages = json!({ "appName": { "message": "Nord VPN" } });
assert_eq!(
lookup_message(&messages, "appName").as_deref(),
Some("Nord VPN")
);
// Chromium compares message keys case-insensitively.
assert_eq!(
lookup_message(&messages, "APPNAME").as_deref(),
Some("Nord VPN")
);
}
#[test]
fn version_dirs_sort_numerically_not_lexicographically() {
let mut dirs = ["1.9.0_0", "1.10.0_0", "1.2.0_0"];
dirs.sort_by_key(|d| version_dir_sort_key(d));
assert_eq!(dirs.last(), Some(&"1.10.0_0"));
}
#[test]
fn malformed_manifest_yields_no_signals() {
// Arrays of non-strings, wrong types, and missing keys must not panic.
let s = signals_of(json!({ "permissions": [1, 2, {"a": "b"}], "host_permissions": "nope" }));
assert_eq!(s, ManifestSignals::default());
assert_eq!(classify(None, &s, true), None);
}
}
+59 -3
View File
@@ -99,7 +99,50 @@ async fn wait_for_vpn_worker_ready(
}
}
/// Serializes worker startup for a given VPN, so two concurrent launches cannot
/// both observe "no worker" and both believe they created it. Benign until a
/// launch guard may stop one on failure; then double-ownership means a
/// cancelled launch tears down a tunnel another profile is using. Mirrors
/// `xray_worker_runner::XRAY_START_LOCK`.
static VPN_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// A started VPN worker plus whether *this* call spawned it.
pub struct VpnWorkerStart {
pub config: VpnWorkerConfig,
/// False when an already-running worker was adopted. Only the creator may
/// stop it while unwinding a failed launch.
pub created: bool,
}
/// Whether any profile with a live browser process is routing through this VPN.
///
/// Extracted from the startup sweep so the launch guard and the sweep agree on
/// what "in use" means instead of each carrying its own copy.
pub fn vpn_id_in_use_by_running_browser(vpn_id: &str) -> bool {
let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else {
// Unable to tell — assume in use rather than tear down a live tunnel.
return true;
};
profiles
.iter()
.filter(|p| p.process_id.is_some_and(is_process_running))
.any(|p| p.vpn_id.as_deref() == Some(vpn_id))
}
/// Hold the start lock across an adopt-sensitive section (a launch guard
/// deciding whether to stop a worker it created).
pub async fn lock_vpn_starts() -> tokio::sync::MutexGuard<'static, ()> {
VPN_START_LOCK.lock().await
}
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
start_vpn_worker_tracked(vpn_id).await.map(|s| s.config)
}
pub async fn start_vpn_worker_tracked(
vpn_id: &str,
) -> Result<VpnWorkerStart, Box<dyn std::error::Error>> {
let _start_guard = VPN_START_LOCK.lock().await;
crate::proxy_runner::ensure_sidecar_version().await?;
for config in list_vpn_worker_configs() {
@@ -117,10 +160,18 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
if let Some(pid) = existing.pid {
if is_process_running(pid) {
if vpn_worker_accepting_connections(&existing).await {
return Ok(existing);
return Ok(VpnWorkerStart {
config: existing,
created: false,
});
}
return wait_for_vpn_worker_ready(&existing.id).await;
return wait_for_vpn_worker_ready(&existing.id)
.await
.map(|config| VpnWorkerStart {
config,
created: false,
});
}
}
// Worker config exists but process is dead, clean up
@@ -263,7 +314,12 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
drop(child);
}
wait_for_vpn_worker_ready(&id).await
wait_for_vpn_worker_ready(&id)
.await
.map(|config| VpnWorkerStart {
config,
created: true,
})
}
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
+4 -6
View File
@@ -721,12 +721,10 @@ impl WayfernManager {
};
if key_path.exists() {
let key_text = std::fs::read_to_string(&key_path).unwrap_or_default();
log::info!(
"Pre-launch: os_crypt_key present ({} bytes, content: '{}')",
key_text.len(),
key_text.trim()
);
// Length only. The contents are the profile's encryption key, and this
// log is the first thing a user attaches to a bug report.
let key_len = std::fs::metadata(&key_path).map(|m| m.len()).unwrap_or(0);
log::info!("Pre-launch: os_crypt_key present ({key_len} bytes)");
} else {
log::warn!("Pre-launch: os_crypt_key NOT FOUND");
}
+182
View File
@@ -0,0 +1,182 @@
//! Client-side window decorations on Linux.
//!
//! The app draws its own titlebar (as it already does on macOS and Windows), so
//! the window is built without server-side decorations. That means the app also
//! owns the window *controls*, and their side and order are a desktop-wide user
//! preference that differs between environments — GNOME defaults to
//! `:minimize,maximize,close` (all on the right), and a user who has moved them
//! to the left expects every app to follow.
//!
//! `GtkSettings::gtk-decoration-layout` is the one place every desktop
//! publishes that preference to GTK applications: GNOME mirrors
//! `org.gnome.desktop.wm.preferences button-layout` into it, and on KDE Plasma
//! `kde-gtk-config` mirrors KWin's decoration button configuration into it.
//! Reading this property therefore gets both environments right without any
//! desktop-specific branching.
use serde::Serialize;
/// Whether this window draws its own titlebar, and how.
#[derive(Debug, Clone, Serialize)]
pub struct WindowDecorations {
/// True when the app owns the titlebar and must draw controls and resize
/// edges. False means the platform still draws a real titlebar and the
/// frontend must render nothing.
pub client_side: bool,
/// The desktop's button layout, e.g. `":minimize,maximize,close"`. Only
/// meaningful when `client_side` is true.
pub layout: Option<String>,
}
/// Whether to drop server-side decorations on this Linux session.
///
/// Enabled everywhere except KDE Plasma on Wayland, and overridable with
/// `DONUT_LINUX_CLIENT_DECORATIONS=1|0`.
///
/// The KDE/Wayland exclusion is deliberate and is about a failure mode, not a
/// preference. GTK3 speaks no `xdg-decoration`; when a window is built
/// undecorated, GTK does not mark it client-decorated, and on Wayland it
/// therefore *announces server-side decorations* to the compositor. mutter
/// ignores that (it never decorates Wayland toplevels), which is why GNOME
/// works. KWin honors it, so Plasma would be free to draw a Breeze titlebar
/// directly above the one the app draws — two titlebars, worse than the
/// feature is good. Whether it actually does depends on the decoration mode
/// KWin advertises, which could not be established from documentation and
/// cannot be tested from here, so this stays off until somebody can run it.
///
/// KDE on X11 is *not* excluded: there the request travels as `_MOTIF_WM_HINTS`,
/// which KWin has honored for as long as it has existed.
#[cfg(target_os = "linux")]
pub fn use_client_side_decorations() -> bool {
if let Ok(value) = std::env::var("DONUT_LINUX_CLIENT_DECORATIONS") {
let forced = matches!(value.trim(), "1" | "true" | "yes");
log::info!("Client-side decorations forced to {forced} by DONUT_LINUX_CLIENT_DECORATIONS");
return forced;
}
let env = |key: &str| std::env::var(key).unwrap_or_default().to_lowercase();
// GDK_BACKEND is a comma-separated preference list ("wayland,x11"), and GDK
// takes the FIRST entry it can open. Testing for a substring would read
// "wayland,x11" as X11 and hand a Plasma Wayland session the undecorated
// path this guard exists to withhold.
let backend = env("GDK_BACKEND");
let preferred = backend
.split(',')
.map(str::trim)
.find(|value| !value.is_empty());
let on_wayland = match preferred {
Some("x11") => false,
Some("wayland") => true,
// Unset or something exotic: fall back to what the session advertises.
_ => {
!std::env::var("WAYLAND_DISPLAY")
.unwrap_or_default()
.is_empty()
&& env("XDG_SESSION_TYPE") != "x11"
}
};
let on_kde = env("XDG_CURRENT_DESKTOP").contains("kde")
|| env("XDG_SESSION_DESKTOP").contains("plasma")
|| env("DESKTOP_SESSION").contains("plasma")
|| !std::env::var("KDE_FULL_SESSION")
.unwrap_or_default()
.is_empty();
if on_kde && on_wayland {
log::info!(
"Keeping server-side decorations: KWin on Wayland may draw its own titlebar over the \
app's. Set DONUT_LINUX_CLIENT_DECORATIONS=1 to override."
);
return false;
}
true
}
#[cfg(target_os = "linux")]
mod imp {
use std::sync::Mutex;
lazy_static::lazy_static! {
static ref LAYOUT: Mutex<Option<String>> = Mutex::new(None);
}
fn store(layout: Option<String>) {
if let Ok(mut slot) = LAYOUT.lock() {
*slot = layout;
}
}
pub fn cached() -> Option<String> {
LAYOUT.lock().ok().and_then(|slot| slot.clone())
}
/// Read the layout and subscribe to changes.
///
/// MUST be called on the GTK main thread — `gtk::Settings::default()` panics
/// elsewhere, and the notify subscription has to be attached from the thread
/// owning the GTK main context. The app's `setup` hook already runs there.
pub fn init<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
use gtk::prelude::*;
use tauri::Emitter;
let Some(settings) = gtk::Settings::default() else {
log::warn!("No GTK settings available; using the default decoration layout");
return;
};
store(settings.gtk_decoration_layout().map(|v| v.to_string()));
log::info!(
"Window decoration layout: {}",
cached().as_deref().unwrap_or("<unset>")
);
// The user can rearrange titlebar buttons while the app is running, and
// every other application follows immediately. Note this never fires where
// the value comes only from gtk-3.0/settings.ini (a KDE X11 box with no
// xsettings daemon) — there it is simply static until restart.
let handle = app.clone();
settings.connect_gtk_decoration_layout_notify(move |settings| {
let layout = settings.gtk_decoration_layout().map(|v| v.to_string());
log::info!(
"Window decoration layout changed to: {}",
layout.as_deref().unwrap_or("<unset>")
);
store(layout.clone());
if let Err(e) = handle.emit("window-decoration-layout-changed", layout) {
log::warn!("Failed to emit window decoration layout change: {e}");
}
});
}
}
#[cfg(not(target_os = "linux"))]
mod imp {
pub fn init<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) {}
}
pub use imp::init;
/// How this window is decorated, and the desktop's button layout when the app
/// owns the titlebar.
#[tauri::command]
pub fn get_window_decoration_layout() -> WindowDecorations {
#[cfg(target_os = "linux")]
{
let client_side = use_client_side_decorations();
WindowDecorations {
client_side,
layout: if client_side { imp::cached() } else { None },
}
}
// Every other platform keeps a real titlebar: macOS makes the native one
// transparent, Windows draws its own controls on a fixed layout.
#[cfg(not(target_os = "linux"))]
{
WindowDecorations {
client_side: false,
layout: None,
}
}
}
+39
View File
@@ -27,3 +27,42 @@ pub enum XrayError {
#[error("failed to serialize Xray client configuration")]
Serialization,
}
impl XrayError {
/// A stable, translatable identifier for *why* a URI was rejected.
///
/// Donut supports one VLESS shape — REALITY + XTLS Vision over TCP — so most
/// rejections are "your setup is a kind we do not support", not "you made a
/// typo". The frontend turns these into a sentence naming the unsupported
/// part; without them every rejection reads as a malformed URI and a user
/// with a working WebSocket or plain-TLS server has no idea why it failed.
pub fn reason_code(&self) -> &'static str {
match self {
Self::UnsupportedScheme => "scheme",
Self::UnsupportedValue { field, .. } | Self::InvalidField { field, .. } => match *field {
"security" => "security",
"flow" => "flow",
"type" => "transport",
"encryption" => "encryption",
"headerType" => "headerType",
"fp" => "fingerprint",
// A malformed sni/public key is the same user-facing problem as a
// missing one, so it earns the same specific help rather than the
// generic "invalid URI".
"sni" | "server_name" => "sni",
"pbk" | "public_key" => "publicKey",
_ => "malformed",
},
Self::MissingField(field) => match *field {
"sni" => "sni",
"pbk" => "publicKey",
"security" => "security",
"flow" => "flow",
_ => "malformed",
},
Self::UnsupportedParameter(_) => "parameter",
Self::DuplicateParameter(_) => "malformed",
Self::InvalidUri | Self::Serialization => "malformed",
}
}
}
+133 -8
View File
@@ -61,10 +61,10 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
};
let port = url.port().ok_or(XrayError::MissingField("port"))?;
let parameters = parse_parameters(&url)?;
require_value(&parameters, "security", "reality")?;
require_value(&parameters, "flow", VlessFlow::Vision.as_str())?;
optional_value(&parameters, "encryption", "none")?;
let (parameters, unsupported) = parse_parameters(&url)?;
// Transport first: it is the most common reason a real-world VLESS server is
// unusable here, and it explains the stray parameters that come with it.
match parameters.get("type").map(String::as_str) {
None | Some("tcp" | "raw") => {}
Some(_) => {
@@ -74,8 +74,17 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
});
}
}
require_value(&parameters, "security", "reality")?;
require_value(&parameters, "flow", VlessFlow::Vision.as_str())?;
optional_value(&parameters, "encryption", "none")?;
optional_value(&parameters, "headerType", "none")?;
// Only once the shape is known-good does an unrecognized parameter become
// the most useful thing to report.
if let Some(name) = unsupported.into_iter().next() {
return Err(XrayError::UnsupportedParameter(name));
}
let server_name = required_parameter(&parameters, "sni")?.to_string();
let public_key = required_parameter(&parameters, "pbk")?.to_string();
let short_id = parameters.get("sid").cloned().unwrap_or_default();
@@ -162,15 +171,28 @@ pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> Xray
query.append_pair("type", "tcp");
query.append_pair("headerType", "none");
}
url.set_fragment(name);
// The parser percent-DECODES the fragment, so the exporter must encode it or
// a name containing `%` (or `#`) comes back different every time the URI is
// canonicalized — the name mutates a little more on each save.
let encoded_name = name.map(|value| urlencoding::encode(value).into_owned());
url.set_fragment(encoded_name.as_deref());
Ok(url.into())
}
fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
/// Split the query into recognized parameters and the names of the rest.
///
/// Unrecognized names are returned rather than rejected on the spot so the
/// caller can report the *shape* problem first. A WebSocket URI always carries
/// `path` (and usually `host`), gRPC carries `serviceName` — naming those keys
/// instead of the transport sends the user deleting parameters when the real
/// answer is that Donut only speaks plain TCP.
fn parse_parameters(url: &Url) -> XrayResult<(HashMap<String, String>, Vec<String>)> {
let mut parameters = HashMap::new();
let mut unsupported = Vec::new();
for (name, value) in url.query_pairs() {
if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) {
return Err(XrayError::UnsupportedParameter(name.into_owned()));
unsupported.push(name.into_owned());
continue;
}
if parameters
.insert(name.to_string(), value.into_owned())
@@ -179,7 +201,7 @@ fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
return Err(XrayError::DuplicateParameter(name.into_owned()));
}
}
Ok(parameters)
Ok((parameters, unsupported))
}
fn required_parameter<'a>(
@@ -230,6 +252,109 @@ mod tests {
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
/// Donut accepts exactly one VLESS shape, so most rejections mean "your
/// server is a kind we do not support" rather than "you mistyped". These pin
/// the reason each rejection reports, because the UI turns it into the one
/// sentence that tells a user with a working WebSocket or plain-TLS server
/// why Donut will not take it.
#[test]
fn unsupported_setups_report_which_part_is_unsupported() {
let good = format!(
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome"
);
assert!(parse_vless_uri(&good).is_ok(), "baseline URI must parse");
let reason = |uri: &str| parse_vless_uri(uri).unwrap_err().reason_code();
// Plain TLS instead of REALITY — the most common real-world setup.
assert_eq!(
reason(&good.replace("security=reality", "security=tls")),
"security"
);
assert_eq!(
reason(&good.replace("flow=xtls-rprx-vision", "flow=none")),
"flow"
);
// WebSocket / gRPC transports.
assert_eq!(reason(&good.replace("type=tcp", "type=ws")), "transport");
assert_eq!(reason(&good.replace("type=tcp", "type=grpc")), "transport");
assert_eq!(reason(&good.replace("&sni=a.com", "")), "sni");
assert_eq!(
reason(&good.replace("&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4", "")),
"publicKey"
);
assert_eq!(reason(&good.replace("vless://", "vmess://")), "scheme");
assert_eq!(reason("not a uri"), "malformed");
}
/// The URIs users actually paste, not canonical-REALITY-with-one-field-changed.
///
/// A real WebSocket link carries `path` (and usually `host`); a gRPC link
/// carries `serviceName`. Those keys are not in SUPPORTED_PARAMETERS, so
/// before the shape was checked first they produced "unsupported option"
/// and sent the user deleting query parameters instead of telling them
/// Donut only speaks plain TCP.
#[test]
fn a_display_name_survives_an_export_parse_round_trip() {
// Percent signs are legal in a fragment, so they used to pass through
// unencoded and then get decoded on the way back in — "50% off" became
// "50 off"-ish and drifted further on every canonicalizing save.
for name in ["50% off", "a#b", "spaced name", "100%25", "üñî"] {
let parsed = parse_vless_uri(&format!(
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
))
.expect("baseline parses");
let exported = export_vless_uri(&parsed.config, Some(name)).expect("exports");
let reparsed = parse_vless_uri(&exported).expect("re-parses");
assert_eq!(
reparsed.name.as_deref(),
Some(name),
"display name mutated across a round trip: {exported}"
);
// And a second round trip must be a fixed point, not drift again.
let exported_again =
export_vless_uri(&reparsed.config, reparsed.name.as_deref()).expect("re-exports");
assert_eq!(exported, exported_again);
}
}
#[test]
fn real_world_websocket_and_grpc_links_name_the_transport() {
let ws = format!(
"vless://{ID}@cdn.example.com:443?encryption=none&security=tls&type=ws\
&path=%2Fray&host=cdn.example.com&sni=cdn.example.com#WS%20node"
);
assert_eq!(
parse_vless_uri(&ws).unwrap_err().reason_code(),
"transport",
"a WebSocket link must be told its transport is unsupported"
);
let grpc = format!(
"vless://{ID}@grpc.example.com:443?encryption=none&security=reality&type=grpc\
&serviceName=gun&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
);
assert_eq!(
parse_vless_uri(&grpc).unwrap_err().reason_code(),
"transport"
);
// A genuinely unknown option on an otherwise-supported URI still reports
// as a parameter problem, which is the accurate answer there.
let odd = format!(
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&madeUpKey=1"
);
assert_eq!(
parse_vless_uri(&odd).unwrap_err().reason_code(),
"parameter"
);
}
fn public_key() -> String {
URL_SAFE_NO_PAD.encode([7_u8; 32])
}
+3 -3
View File
@@ -165,7 +165,7 @@ pub async fn start_xray_worker(
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let _start_guard = XRAY_START_LOCK.lock().await;
parse_vless_uri(vless_uri)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
crate::proxy_runner::ensure_sidecar_version().await?;
ensure_xray_binary()?;
let owner_pid = std::process::id();
@@ -521,14 +521,14 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::erro
save_xray_worker_config_to_path(&config, config_path)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let parsed = parse_vless_uri(&config.vless_uri)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
let runtime = XrayClientRuntime {
listen_port: config.local_port,
username: config.username.clone(),
password: config.password.clone(),
};
let runtime_json = build_client_config_json(&parsed.config, &runtime)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
write_xray_runtime_config(&config.id, runtime_json.as_bytes())
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let runtime_path = crate::xray_worker_storage::xray_runtime_config_path(&config.id);
+35
View File
@@ -182,6 +182,39 @@ fn worker_is_tombstoned(id: &str) -> bool {
xray_worker_tombstone_path(id).exists()
}
/// How long a tombstone has to outlive its worker.
///
/// It only has to survive long enough to beat a write already in flight from
/// the process that owned that id. A day is many orders of magnitude more than
/// that, and bounds a directory that otherwise gains a file per worker forever.
const TOMBSTONE_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
/// Drop tombstones old enough that nothing could still be racing them.
fn prune_stale_tombstones() {
let Ok(entries) = fs::read_dir(crate::proxy_storage::get_storage_dir()) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("stopped") {
continue;
}
let aged_out = path
.metadata()
.and_then(|meta| meta.modified())
.map(|modified| {
modified
.elapsed()
.map(|age| age > TOMBSTONE_TTL)
.unwrap_or(false)
})
.unwrap_or(false);
if aged_out {
let _ = fs::remove_file(&path);
}
}
}
pub fn create_xray_worker_log(id: &str) -> std::io::Result<std::fs::File> {
ensure_private_storage_dir()?;
if worker_is_tombstoned(id) {
@@ -275,6 +308,8 @@ pub fn delete_xray_worker_config(id: &str) -> bool {
}
pub fn list_xray_worker_configs() -> Vec<XrayWorkerConfig> {
// Cheap, and this is the one call every sweep already makes.
prune_stale_tombstones();
let storage_dir = crate::proxy_storage::get_storage_dir();
let Ok(entries) = fs::read_dir(storage_dir) else {
return Vec::new();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.28.2",
"version": "0.29.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+52 -35
View File
@@ -464,15 +464,10 @@ async fn test_local_proxy_direct() -> Result<(), Box<dyn std::error::Error + Sen
);
// Verify proxy is listening
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => {
println!("Proxy is listening on port {local_port}");
}
Err(e) => {
return Err(format!("Proxy port {local_port} is not listening: {e}").into());
}
if !wait_for_port_open(local_port, Duration::from_secs(10)).await {
return Err(format!("Proxy port {local_port} is not listening").into());
}
println!("Proxy is listening on port {local_port}");
// Test making an HTTP request through the proxy
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
@@ -524,11 +519,10 @@ async fn test_chained_local_proxies() -> Result<(), Box<dyn std::error::Error +
println!("First proxy started on port {}", proxy1_port);
// Wait for first proxy to be ready
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", proxy1_port)).await {
Ok(_) => println!("First proxy is ready"),
Err(e) => return Err(format!("First proxy not ready: {e}").into()),
if !wait_for_port_open(proxy1_port, Duration::from_secs(10)).await {
return Err("First proxy not ready".into());
}
println!("First proxy is ready");
// Start second proxy chained to first proxy
let output2 = TestUtils::execute_command(
@@ -565,11 +559,10 @@ async fn test_chained_local_proxies() -> Result<(), Box<dyn std::error::Error +
);
// Wait for second proxy to be ready
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", proxy2_port)).await {
Ok(_) => println!("Second proxy is ready"),
Err(e) => return Err(format!("Second proxy not ready: {e}").into()),
if !wait_for_port_open(proxy2_port, Duration::from_secs(10)).await {
return Err("Second proxy not ready".into());
}
println!("Second proxy is ready");
// Test making an HTTP request through the chained proxy
let mut stream = TcpStream::connect(("127.0.0.1", proxy2_port)).await?;
@@ -669,16 +662,11 @@ async fn test_local_proxy_with_http_upstream(
println!("Proxy started: id={}, port={}", proxy_id, local_port);
// Verify proxy is listening
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => {
println!("Proxy is listening on port {local_port}");
}
Err(e) => {
upstream_handle.abort();
return Err(format!("Proxy port {local_port} is not listening: {e}").into());
}
if !wait_for_port_open(local_port, Duration::from_secs(10)).await {
upstream_handle.abort();
return Err(format!("Proxy port {local_port} is not listening").into());
}
println!("Proxy is listening on port {local_port}");
// Cleanup
tracker.cleanup_all().await;
@@ -955,11 +943,10 @@ async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync
let local_port = config["localPort"].as_u64().unwrap() as u16;
// Verify proxy is running
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => println!("Proxy is running"),
Err(_) => return Err("Proxy is not running".into()),
if !wait_for_port_open(local_port, Duration::from_secs(10)).await {
return Err("Proxy is not running".into());
}
println!("Proxy is running");
// Stop the proxy
let stop_output =
@@ -969,14 +956,11 @@ async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync
return Err("Failed to stop proxy".into());
}
// Wait a bit for the process to exit
sleep(Duration::from_millis(500)).await;
// Verify proxy is stopped (connection should fail)
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => return Err("Proxy should be stopped but is still listening".into()),
Err(_) => println!("Proxy successfully stopped"),
if !wait_for_port_closed(local_port, Duration::from_secs(10)).await {
return Err("Proxy should be stopped but is still listening".into());
}
println!("Proxy successfully stopped");
Ok(())
}
@@ -1785,6 +1769,39 @@ impl Drop for StubBrowser {
}
}
/// Wait for a port to start accepting connections.
///
/// `proxy start` returns once the worker is spawned, not once it has bound its
/// listener, so a fixed sleep is a bet on how fast the runner is. Poll instead.
async fn wait_for_port_open(port: u16, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
return true;
}
sleep(Duration::from_millis(100)).await;
}
false
}
/// Wait for a listening port to stop accepting connections.
///
/// `proxy stop` returns once the worker has been told to exit, not once it has
/// actually gone, so the listener can outlive the command by however long the
/// process takes to unwind. That gap is invisible on an idle laptop and lands
/// squarely on a loaded CI runner, so poll to a deadline rather than sleeping a
/// fixed amount and hoping. Returns false if it is still accepting at the end.
async fn wait_for_port_closed(port: u16, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).await.is_err() {
return true;
}
sleep(Duration::from_millis(100)).await;
}
false
}
/// Wait for a worker to remove its own config, which it does immediately before
/// exiting. Returns false if it is still there when the deadline passes.
async fn wait_for_worker_exit(proxy_id: &str, timeout: Duration) -> bool {
+45 -4
View File
@@ -627,6 +627,37 @@ async fn cleanup_runtime() {
test_harness::stop_vpn_servers().await;
}
/// Request through the proxy until the tunnel behind it actually carries the
/// traffic, or the deadline passes.
///
/// Returns the last response either way, so a genuine failure still asserts
/// against the real body rather than a timeout message.
async fn wait_for_tunnel(
local_port: u16,
url: &str,
host_header: &str,
timeout: Duration,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let last = match raw_http_request_via_proxy(local_port, url, host_header).await {
Ok(response) => {
if response.contains("WG-TUNNEL-OK") {
return Ok(response);
}
response
}
Err(e) => format!("request error: {e}"),
};
if tokio::time::Instant::now() >= deadline {
return Ok(last);
}
sleep(Duration::from_millis(250)).await;
}
}
async fn wait_for_file(
path: &std::path::Path,
timeout: Duration,
@@ -661,12 +692,22 @@ async fn run_proxy_feature_suite(
let proxy =
start_proxy_with_upstream(binary_path, &vpn_upstream, &[], None, Some(&profile_id)).await?;
sleep(Duration::from_millis(500)).await;
let internal_url = format!("http://{}:8080/", server_tunnel_ip);
let internal_host = format!("{}:8080", server_tunnel_ip);
let http_response =
raw_http_request_via_proxy(proxy.local_port, &internal_url, &internal_host).await?;
// The proxy answers as soon as it is listening, but the route behind it is
// not ready until the WireGuard handshake completes and the in-tunnel server
// accepts. A fixed sleep raced that on a loaded runner and came back
// `502 Bad Gateway`, which is the tunnel not being up yet rather than
// anything under test being wrong. Poll to a deadline instead, the same way
// `wait_for_file` does below.
let http_response = wait_for_tunnel(
proxy.local_port,
&internal_url,
&internal_host,
Duration::from_secs(20),
)
.await?;
assert!(
http_response.contains("WG-TUNNEL-OK"),
"HTTP traffic through donut-proxy+VPN tunnel should succeed, got: {}",
+365 -44
View File
@@ -13,11 +13,6 @@ import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
import { CommandPalette } from "@/components/command-palette";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import {
type ConsistencyResult,
ConsistencyWarningDialog,
isConsistencyWarningSuppressed,
} from "@/components/consistency-warning-dialog";
import { CookieBotPage, type CookieBotTab } from "@/components/cookie-bot-page";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
@@ -33,6 +28,11 @@ import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
import { PermissionDialog } from "@/components/permission-dialog";
import {
type GateDecision,
type GateFindings,
PreLaunchGateDialog,
} from "@/components/pre-launch-gate-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
type PasswordDialogMode,
@@ -67,7 +67,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
import { MOTION_EASE_OUT } from "@/lib/motion";
import {
@@ -88,7 +88,42 @@ import {
showSyncProgressToast,
showToast,
} from "@/lib/toast-utils";
import type { BrowserProfile, SyncSettings, WayfernConfig } from "@/types";
import type {
BrowserProfile,
ConsistencyResult,
PreLaunchChecks,
SyncSettings,
WayfernConfig,
} from "@/types";
type GateRequest = {
profile: BrowserProfile;
findings: GateFindings;
};
type LaunchResult = {
status: "launched" | "cancelled" | "blocked";
};
/**
* Rebuild the mismatch detail the gate dialog renders from a
* FINGERPRINT_EXIT_MISMATCH error's params. Every param is a string, because
* backend error params always are.
*/
function consistencyFromErrorParams(
params?: Record<string, string>,
): ConsistencyResult {
return {
consistent: false,
checked: true,
exit_ip: params?.exitIp || null,
exit_country_code: params?.exitCountry || null,
exit_timezone: params?.exitTimezone || null,
fingerprint_timezone: params?.fingerprintTimezone || null,
fingerprint_language: params?.fingerprintLanguage || null,
mismatches: (params?.mismatches ?? "").split(",").filter(Boolean),
};
}
type BrowserTypeString = "wayfern";
@@ -252,7 +287,7 @@ export default function Home() {
const { user: cloudUser } = useCloudAuth();
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
// Bulk run/stop is a paid (browser automation) feature, matching the
// /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop
// /v1/profiles/batch/run API gate. Free/solo users see the bulk Run/Stop
// actions disabled with a Pro badge.
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
// The rail needs to show a live run from every page, so the shell subscribes
@@ -279,7 +314,12 @@ export default function Home() {
}
}, [cloudUser]);
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured;
// Cloud sync follows `cloudBackup`, NOT `crossOsFingerprints`. They agreed on
// every plan until Solo, which buys 20 cloud backups and deliberately has no
// fingerprint editing — so deriving sync from the fingerprint capability put a
// Pro badge on the one feature a Solo customer is paying for.
const cloudBackupUnlocked = getEntitlements(cloudUser).cloudBackup;
const syncUnlocked = cloudBackupUnlocked || selfHostedSyncConfigured;
const [currentPage, setCurrentPage] = useState<AppPage>("profiles");
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
@@ -365,10 +405,40 @@ export default function Home() {
useState<BrowserProfile | null>(null);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
const [consistencyWarning, setConsistencyWarning] = useState<{
profile: BrowserProfile;
result: ConsistencyResult;
// Pre-launch gate. Requests queue instead of overwriting a single resolver:
// a bulk run enqueues one per profile, and every waiter must settle or the
// Promise.allSettled below it never resolves and the bulk spinner sticks.
const gateQueueRef = useRef<
Array<{
id: number;
req: GateRequest;
/// The bulk run this request belongs to, or undefined for a single
/// launch. Carried per entry so a blanket "apply to the rest" can only
/// ever claim the run its own dialog came from.
runId: number | undefined;
resolve: (decision: GateDecision) => void;
}>
>([]);
const gateRequestSeqRef = useRef(0);
const [gateState, setGateState] = useState<{
id: number;
req: GateRequest;
remaining: number;
} | null>(null);
// Set when the user ticks "apply to the remaining profiles" during a bulk
// run, so the rest are answered without prompting again. Scoped to one bulk
// run and to the severity it was given for.
const blanketGateDecisionRef = useRef<{
decision: GateDecision;
/// Only auto-answers gates no more severe than the one the user saw. A
/// choice made on an extension warning must never silently bypass a hard
/// block on a later profile.
coversBlocking: boolean;
/// Identifies the bulk run, so a single launch started while a bulk run is
/// in flight still gets its own dialog.
runId: number;
} | null>(null);
const bulkRunIdRef = useRef(0);
// Owned by page.tsx so the command palette can request opening the profile
// info dialog. ProfilesDataTable consumes it through controlled props.
const [profileInfoDialog, setProfileInfoDialog] =
@@ -933,8 +1003,153 @@ export default function Home() {
[selectedGroupId, t],
);
// Unattended launches — REST and MCP automation — are the only ones the
// backend gate lets past a measured mismatch, because there is no dialog for
// them to answer. Without a listener that finding was emitted into the void.
useEffect(() => {
const unlisten = listen<ConsistencyResult>(
"fingerprint-consistency-warning",
(event) => {
const { exit_timezone, fingerprint_timezone } = event.payload;
showErrorToast(t("backendErrors.fingerprintExitMismatch"), {
description:
exit_timezone && fingerprint_timezone
? t("consistencyWarning.timezoneDetail", {
exit: exit_timezone,
fingerprint: fingerprint_timezone,
})
: undefined,
id: `fingerprint-mismatch-${exit_timezone ?? "unknown"}`,
});
},
);
return () => {
void unlisten.then((fn) => {
fn();
});
};
}, [t]);
// Show the queue's head, and how many are waiting behind it.
const syncGateUi = useCallback(() => {
const queue = gateQueueRef.current;
setGateState(
queue.length > 0
? { id: queue[0].id, req: queue[0].req, remaining: queue.length - 1 }
: null,
);
}, []);
const requestGateDecision = useCallback(
(req: GateRequest, runId?: number): Promise<GateDecision> => {
const blanket = blanketGateDecisionRef.current;
const isBlocking = req.findings.fingerprint !== null;
if (
blanket &&
blanket.runId === runId &&
(blanket.coversBlocking || !isBlocking)
) {
// A blanket answer covers only whether to launch. The acknowledgements
// it carried were about the first profile's specific mismatch and
// extensions, and must not be persisted against profiles the user
// never saw.
return Promise.resolve({
...blanket.decision,
ackFingerprint: false,
ackExtensionKeys: [],
});
}
return new Promise<GateDecision>((resolve) => {
gateRequestSeqRef.current += 1;
gateQueueRef.current.push({
id: gateRequestSeqRef.current,
req,
runId,
resolve,
});
syncGateUi();
});
},
[syncGateUi],
);
const settleGate = useCallback(
(decision: GateDecision) => {
const entry = gateQueueRef.current.shift();
if (!entry) {
return;
}
entry.resolve(decision);
if (decision.applyToRemaining) {
const coversBlocking = entry.req.findings.fingerprint !== null;
// Only a bulk run gets a standing blanket, and it claims the run the
// answered dialog belonged to — never whichever run happens to be in
// flight when the dialog is settled. Outside a run there is nothing to
// scope one to, and a session-wide blanket would silently answer
// unrelated launches later. The queue is still drained either way,
// which is what the checkbox actually promises.
if (entry.runId !== undefined) {
blanketGateDecisionRef.current = {
decision,
coversBlocking,
runId: entry.runId,
};
}
// Drain the queue rather than leaving promises pending forever — but
// only those the blanket actually covers. A hard block still deserves
// its own dialog even after the user blanket-approved a warning, and a
// launch started outside this run was never part of the answer.
const remaining = gateQueueRef.current.splice(0);
const kept = [];
for (const queued of remaining) {
const covered =
queued.runId === entry.runId &&
(coversBlocking || queued.req.findings.fingerprint === null);
if (!covered) {
kept.push(queued);
continue;
}
queued.resolve({
...decision,
ackFingerprint: false,
ackExtensionKeys: [],
});
}
gateQueueRef.current = kept;
}
syncGateUi();
},
[syncGateUi],
);
const persistGateAcks = useCallback(
async (profileId: string, decision: GateDecision) => {
// Only on proceed. Cancel is the autofocused default action, so a stray
// Enter would otherwise permanently disarm the gate for this profile.
if (!decision.proceed) {
return;
}
if (!decision.ackFingerprint && decision.ackExtensionKeys.length === 0) {
return;
}
try {
await invoke("ack_launch_gate", {
profileId,
ackFingerprint: decision.ackFingerprint,
ackExtensionKeys: decision.ackExtensionKeys,
});
} catch (err) {
console.warn("Failed to persist launch gate acknowledgement:", err);
}
},
[],
);
const launchProfile = useCallback(
async (profile: BrowserProfile) => {
async (
profile: BrowserProfile,
opts?: { bulkRunId?: number },
): Promise<LaunchResult> => {
console.log("Starting launch for profile:", profile.name);
// Password-protected: must be unlocked before launch
@@ -947,7 +1162,7 @@ export default function Home() {
pendingLaunchAfterUnlockRef.current = profile;
setPasswordDialogMode("unlock");
setPasswordDialogProfile(profile);
return;
return { status: "cancelled" };
}
} catch (err) {
console.error("Failed to check profile lock state:", err);
@@ -966,7 +1181,7 @@ export default function Home() {
setWindowResizeWarningOpen(true);
});
if (!proceed) {
return;
return { status: "cancelled" };
}
}
} catch (error) {
@@ -974,30 +1189,117 @@ export default function Home() {
}
}
// Tier 1: purely local checks — an extension scan and a cached exit
// verdict. No network, no worker started, so a profile whose exit is
// already known blocks before the launch touches anything.
let consentToken: string | null = null;
// Kept for the tier-2 dialog below: the extensions are the same ones,
// and a mismatch measured mid-launch is exactly when knowing that one of
// them can change the proxy matters most. Minus anything the user just
// acknowledged, so a box they ticked seconds ago is not shown again.
let localChecks: PreLaunchChecks | null = null;
let ackedExtensionKeys: string[] = [];
try {
// One-shot migration of the old per-profile "don't warn again" flag,
// so a user who already dismissed this profile isn't hard-blocked by
// the new gate. Granted against the profile's current exit, which is
// the mismatch they were looking at when they dismissed it.
const legacySkipKey = `consistency-warn-skip-${profile.id}`;
if (localStorage.getItem(legacySkipKey) === "1") {
await invoke("ack_launch_gate", {
profileId: profile.id,
ackFingerprint: true,
ackExtensionKeys: [],
}).catch((err: unknown) => {
console.warn("Failed to migrate consistency skip flag:", err);
});
localStorage.removeItem(legacySkipKey);
}
const checks = await invoke<PreLaunchChecks>(
"get_profile_pre_launch_checks",
{ profileId: profile.id },
);
localChecks = checks;
const blocked =
checks.consistency.checked && !checks.consistency.consistent;
if (blocked || checks.vpn_extensions.length > 0) {
const decision = await requestGateDecision(
{
profile,
findings: {
vpnExtensions: checks.vpn_extensions,
scanState: checks.scan_state,
fingerprint: blocked ? checks.consistency : null,
measurementUnreliable: checks.exit_measurement_unreliable,
probePending: checks.exit_probe_pending,
},
},
opts?.bulkRunId,
);
await persistGateAcks(profile.id, decision);
if (!decision.proceed) {
return { status: "cancelled" };
}
ackedExtensionKeys = decision.ackExtensionKeys;
consentToken = checks.consent_token;
}
} catch (err) {
// Same posture as the password and window-resize gates: a check that
// cannot run must not make profiles unlaunchable.
console.warn("Pre-launch checks failed, launching anyway:", err);
}
try {
const result = await invoke<BrowserProfile>("launch_browser_profile", {
profile,
consentToken,
});
console.log("Successfully launched profile:", result.name);
// Non-blocking: after a successful launch, check that the proxy exit
// node's timezone/country agrees with the fingerprint. A mismatch is a
// strong anti-bot tell even though the real device never leaks.
if (profile.proxy_id && !isConsistencyWarningSuppressed(profile.id)) {
void invoke<ConsistencyResult>(
"check_profile_fingerprint_consistency",
{ profileId: profile.id },
)
.then((res) => {
if (res.checked && !res.consistent) {
setConsistencyWarning({ profile, result: res });
}
})
.catch((e) => {
console.warn("Consistency check failed:", e);
});
}
return { status: "launched" };
} catch (err: unknown) {
// Tier 2: the enforcing gate measured the exit mid-launch and stopped
// before spawning the browser. Offer the same decision, then retry
// exactly once with the token it minted — bounded, so a gate loop is
// structurally impossible.
const parsed = parseBackendError(err);
if (parsed?.code === "FINGERPRINT_EXIT_MISMATCH") {
const decision = await requestGateDecision(
{
profile,
findings: {
vpnExtensions: (localChecks?.vpn_extensions ?? []).filter(
(ext) => !ackedExtensionKeys.includes(ext.key),
),
scanState: localChecks?.scan_state ?? "scanned",
fingerprint: consistencyFromErrorParams(parsed.params),
measurementUnreliable:
localChecks?.exit_measurement_unreliable ?? false,
probePending: false,
},
},
opts?.bulkRunId,
);
await persistGateAcks(profile.id, decision);
if (!decision.proceed) {
return { status: "cancelled" };
}
try {
await invoke<BrowserProfile>("launch_browser_profile", {
profile,
consentToken: parsed.params?.token ?? null,
});
return { status: "launched" };
} catch (retryErr: unknown) {
showErrorToast(
t("errors.launchBrowserFailed", {
error: translateBackendError(t, retryErr),
}),
);
return { status: "blocked" };
}
}
console.error("Failed to launch browser:", err);
const errorMessage = translateBackendError(t, err);
showErrorToast(
@@ -1006,7 +1308,7 @@ export default function Home() {
throw err;
}
},
[t],
[persistGateAcks, requestGateDecision, t],
);
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
@@ -1203,15 +1505,34 @@ export default function Home() {
const executeBulkRun = useCallback(
async (targets: BrowserProfile[]) => {
setIsBulkActing(true);
blanketGateDecisionRef.current = null;
bulkRunIdRef.current += 1;
const runId = bulkRunIdRef.current;
try {
await Promise.allSettled(targets.map((p) => launchProfile(p)));
const results = await Promise.allSettled(
targets.map((p) => launchProfile(p, { bulkRunId: runId })),
);
const stopped = results.filter(
(r) => r.status === "fulfilled" && r.value.status !== "launched",
).length;
if (stopped > 0) {
// Previously a declined launch resolved to undefined, so allSettled
// reported success and the user was told nothing.
showErrorToast(
t("prelaunchGate.cancelledSummary", {
cancelled: stopped,
total: targets.length,
}),
);
}
setSelectedProfiles([]);
} finally {
blanketGateDecisionRef.current = null;
setIsBulkActing(false);
setPendingBulkAction(null);
}
},
[launchProfile],
[launchProfile, t],
);
const executeBulkStop = useCallback(
@@ -1898,14 +2219,14 @@ export default function Home() {
}}
/>
<ConsistencyWarningDialog
isOpen={consistencyWarning !== null}
onClose={() => {
setConsistencyWarning(null);
}}
profileName={consistencyWarning?.profile.name ?? ""}
profileId={consistencyWarning?.profile.id ?? ""}
result={consistencyWarning?.result ?? null}
<PreLaunchGateDialog
isOpen={gateState !== null}
profileName={gateState?.req.profile.name ?? ""}
profileId={gateState?.req.profile.id ?? ""}
requestId={gateState?.id ?? 0}
findings={gateState?.req.findings ?? null}
remainingCount={gateState?.remaining ?? 0}
onResult={settleGate}
/>
{pendingUrls.map((pendingUrl) => (
@@ -1,176 +0,0 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
export interface ConsistencyResult {
consistent: boolean;
checked: boolean;
exit_ip: string | null;
exit_country_code: string | null;
exit_timezone: string | null;
fingerprint_timezone: string | null;
fingerprint_language: string | null;
mismatches: string[];
}
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
export function isConsistencyWarningSuppressed(profileId: string): boolean {
try {
return (
localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" ||
localStorage.getItem(perProfileKey(profileId)) === "1"
);
} catch {
return false;
}
}
interface ConsistencyWarningDialogProps {
isOpen: boolean;
onClose: () => void;
profileName: string;
profileId: string;
result: ConsistencyResult | null;
}
export function ConsistencyWarningDialog({
isOpen,
onClose,
profileName,
profileId,
result,
}: ConsistencyWarningDialogProps) {
const { t } = useTranslation();
const [dontWarnAgain, setDontWarnAgain] = useState(false);
const [isMatching, setIsMatching] = useState(false);
const handleClose = () => {
if (dontWarnAgain) {
try {
localStorage.setItem(perProfileKey(profileId), "1");
} catch {
// localStorage unavailable — nothing to persist
}
}
setDontWarnAgain(false);
onClose();
};
const mismatches = result?.mismatches ?? [];
const exitIp = result?.exit_ip ?? null;
const handleMatch = async () => {
if (!exitIp) {
return;
}
setIsMatching(true);
try {
await invoke("match_profile_fingerprint_to_exit", {
profileId,
exitIp,
});
showSuccessToast(t("consistencyWarning.matchSuccess"));
handleClose();
} catch (e) {
showErrorToast(translateBackendError(t, e));
} finally {
setIsMatching(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning-text" />
{t("consistencyWarning.title")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">
{t("consistencyWarning.intro", { name: profileName })}
</p>
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/10 p-3">
{mismatches.includes("timezone") && (
<div>
<p className="font-medium">
{t("consistencyWarning.timezoneTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.timezoneDetail", {
exit: result?.exit_timezone ?? "?",
fingerprint: result?.fingerprint_timezone ?? "?",
})}
</p>
</div>
)}
{mismatches.includes("language") && (
<div>
<p className="font-medium">
{t("consistencyWarning.languageTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.languageDetail", {
country: result?.exit_country_code ?? "?",
fingerprint: result?.fingerprint_language ?? "?",
})}
</p>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.explainer")}
</p>
<label
htmlFor="consistency-dont-warn"
className="flex cursor-pointer items-center gap-2 text-xs"
>
<Checkbox
id="consistency-dont-warn"
checked={dontWarnAgain}
onCheckedChange={(v) => setDontWarnAgain(v === true)}
/>
{t("consistencyWarning.dontWarnAgain")}
</label>
</div>
<div className="flex justify-end gap-2">
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isMatching}
>
{t("common.buttons.close")}
</RippleButton>
{exitIp && (
<RippleButton onClick={handleMatch} disabled={isMatching}>
{isMatching
? t("consistencyWarning.matching")
: t("consistencyWarning.matchToProxy")}
</RippleButton>
)}
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -11,9 +11,11 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import type {
CookieBotRun,
CookieBotSchedule,
CookieBotSlot,
RemoteHoursQuota,
} from "@/lib/cookie-bot";
import { MOTION_EASE_OUT } from "@/lib/motion";
@@ -66,6 +68,84 @@ export function nightsPerWeek(mask: number): number {
return count;
}
/* -------------------------------------------------------------------------- */
/* Slots */
/* -------------------------------------------------------------------------- */
/**
* Every time-of-day an enrolment fires, from whichever shape the server sent.
*
* ALWAYS at least one slot. A server that predates multi-slot scheduling sends
* only the mirrored `run_at_minute` / `days_mask` pair, and a renderer that read
* `slots` directly would show an enrolment as firing at no time at all. Reading
* the wire through here is what keeps that fallback in one place.
*/
export function scheduleSlots(schedule: {
slots?: CookieBotSlot[];
run_at_minute: number;
days_mask: number;
}): CookieBotSlot[] {
const slots = schedule.slots ?? [];
if (slots.length > 0) return slots;
return [
{ days_mask: schedule.days_mask, run_at_minute: schedule.run_at_minute },
];
}
/**
* How many times a week a whole calendar fires.
*
* Counted across slots, not read off the first one: an enrolment with three
* slots costs three times the hours, and the budget estimate beside it is the
* only place a user sees that before committing.
*
* DISTINCT (weekday, minute) pairs rather than a sum of night counts, because
* two slots landing on the same weekday at the same minute are the same
* instant and the server dispatches them as ONE run `upcomingSlotsMulti`
* collapses coincident fires. Summing quoted a Mon+Tue and a Tue+Wed slot at
* 02:00 as four nights when it is three, and that inflated figure is what the
* over-budget warning is compared against.
*/
export function weeklyRuns(
slots: { days_mask: number; run_at_minute: number }[],
): number {
const fires = new Set<number>();
for (const slot of slots) {
for (let bit = 0; bit < 7; bit += 1) {
if ((slot.days_mask & (1 << bit)) !== 0) {
fires.add(bit * 1440 + slot.run_at_minute);
}
}
}
return fires.size;
}
/**
* Monday-first weekday names, from the viewer's own locale.
*
* Derived rather than translated into ten locale files: `narrow` already gives
* each language its own single-letter convention, and a hand-written table
* would be ten more places for Monday-first to be got wrong. The reference week
* is formatted in UTC so a user east of Greenwich does not see it shift by a
* day.
*/
export function weekdayNames(): { narrow: string; long: string }[] {
// 2024-01-01 was a Monday, which is bit 0 of the server's mask.
const monday = Date.UTC(2024, 0, 1);
const narrow = new Intl.DateTimeFormat(undefined, {
weekday: "narrow",
timeZone: "UTC",
});
const long = new Intl.DateTimeFormat(undefined, {
weekday: "long",
timeZone: "UTC",
});
return Array.from({ length: 7 }, (_, index) => {
const day = new Date(monday + index * 24 * 60 * 60 * 1000);
return { narrow: narrow.format(day), long: long.format(day) };
});
}
/** A human cadence label. Unknown masks fall back to the night count. */
export function describeCadence(t: TFunction, mask: number): string {
const id = cadenceForMask(mask);
@@ -237,6 +317,24 @@ export function preflight(profile: BrowserProfile): PreflightResult {
return ELIGIBLE;
}
/**
* Whether this profile could be launched on a remote host.
*
* A strict subset of {@link preflight}: a remote session needs the profile to
* exist in cloud storage in a form a host can read, and nothing more. The bot's
* extra requirement an exit node exists because a night of unattended
* traffic from a datacenter address is worse for the profile than not warming
* it, and that reasoning does not apply to a session the user is driving.
*
* Mirrors `remote_launch_profile_rules` in `api_server.rs`, which is
* authoritative; this only avoids offering an action that would be refused.
*/
export function canLaunchRemotely(profile: BrowserProfile): boolean {
const syncMode = profile.sync_mode ?? "Disabled";
if (syncMode === "Disabled" || syncMode === "Encrypted") return false;
return resolvedOs(profile) !== null;
}
export function preflightReason(t: TFunction, result: PreflightResult): string {
switch (result.code) {
case "syncOff":
@@ -391,6 +489,36 @@ export function outcomeLabel(
return key ? t(key) : t("cookieBot.outcome.unknown", { code });
}
/**
* The three refusals only the saved-list routes can raise.
*
* They are absent from the shared `backendErrors` table, so
* `translateBackendError` renders them through its unknown-code fallback: a
* user who reuses a name would be told "Something went wrong:
* COOKIE_BOT_TEMPLATE_NAME_TAKEN" instead of that the name is taken, in the one
* dialog where the fix is a single keystroke. Everything else a signed-out
* desktop, an unreachable cloud still goes through the shared translator.
*/
const TEMPLATE_ERROR_KEYS: Record<string, string> = {
COOKIE_BOT_TEMPLATE_NAME_TAKEN: "cookieBot.enrol.templateNameTaken",
COOKIE_BOT_INVALID_TEMPLATE_NAME: "cookieBot.enrol.templateNameInvalid",
COOKIE_BOT_TEMPLATE_NOT_FOUND: "cookieBot.enrol.templateMissing",
};
export function templateErrorMessage(t: TFunction, error: unknown): string {
const parsed = parseBackendError(error);
const key = parsed ? TEMPLATE_ERROR_KEYS[parsed.code] : undefined;
if (!key) return translateBackendError(t, error);
const max = parsed?.params?.max;
// The server does not always send a limit. Interpolating an empty string
// rendered "a name of characters or fewer", so fall back to wording that
// does not need the number.
if (key === "cookieBot.enrol.templateNameInvalid" && !max) {
return t("cookieBot.enrol.templateNameInvalidNoMax");
}
return t(key, { max });
}
/**
* The session state machine, named honestly. `provisioning -> ready -> live ->
* closed`, with `error` reachable from any of the first three, is what the
+24 -2
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { GroupWithCount } from "@/types";
@@ -59,8 +60,14 @@ const HomeHeader = ({
}, []);
const isMacOS = platform === "macos";
const isLinux = platform === "linux";
const showProfileToolbar = !pageTitle;
// Same hook the controls use, so the reserved space can never disagree with
// what is actually drawn.
const decorations = useWindowDecorations();
const linuxLayout = decorations.clientSide ? decorations.layout : null;
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
const holdTimeoutRef = useRef<number | null>(null);
@@ -179,14 +186,29 @@ const HomeHeader = ({
onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick}
className={cn(
"flex h-11 items-center gap-2 border-b border-border bg-card pl-3 select-none",
"flex h-11 items-center gap-2 border-b border-border bg-card select-none",
// Windows: WindowDragArea renders three 44px native-style controls
// (minimize + maximize/restore + close) fixed at top-right with
// z-50, total 132px wide. Reserve 144px on the right edge so the
// "+ New" button and search input clear them with a few pixels of
// breathing room and never sit underneath the controls.
isWindows ? "pr-[144px]" : "pr-3",
isWindows ? "pl-3 pr-[144px]" : null,
// Linux reserves its space through the inline style below, because the
// desktop chooses which side the controls sit on and how many there
// are. Everything else keeps the plain symmetric padding.
!isWindows && !isLinux ? "pl-3 pr-3" : null,
)}
style={
isLinux
? {
// Each control is 44px wide; add the usual 12px gutter. Before
// the layout resolves, fall back to the gutter alone rather than
// to no padding, which would visibly shift the content.
paddingLeft: (linuxLayout?.left.length ?? 0) * 44 + 12,
paddingRight: (linuxLayout?.right.length ?? 0) * 44 + 12,
}
: undefined
}
>
{isMacOS && (
<div
+233 -89
View File
@@ -53,10 +53,88 @@ import type {
ImportProfileItem,
ProfileImportBatchResult,
ProfileImportProgress,
ProfileImportReport,
WayfernConfig,
} from "@/types";
import { RippleButton } from "./ui/ripple";
/**
* What an import actually carried, and what it could not.
*
* The counts matter more than they look: an import that reports zero of
* everything is the exact symptom of the bug where copied data landed where
* the browser never reads it, and it used to be indistinguishable from success.
*/
function ImportReportSummary({ report }: { report: ProfileImportReport }) {
const { t } = useTranslation();
// Label-then-value rather than "{{count}} cookies": it keeps the row scannable
// and sidesteps needing correct plural forms in ten languages.
const carried = (
[
["importProfile.reportCookies", report.cookies_migrated],
["importProfile.reportPasswords", report.passwords_migrated],
["importProfile.reportAutofill", report.payment_methods_migrated],
["importProfile.reportExtensions", report.extensions_migrated],
["importProfile.reportHistory", report.history_entries],
["importProfile.reportBookmarks", report.bookmarks],
["importProfile.reportLocalStorage", report.local_storage_origins],
] as const
)
.filter(([, count]) => count > 0)
.map(([key, count]) => `${t(key)} ${count.toLocaleString()}`);
const unrecoverable =
report.cookies_unrecoverable +
report.passwords_unrecoverable +
report.payment_methods_unrecoverable;
return (
<div className="mt-0.5 space-y-0.5 pl-1 text-xs text-muted-foreground">
<p>
{carried.length > 0
? carried.join(" · ")
: t("importProfile.reportNothingCarried")}
</p>
{unrecoverable > 0 && (
<p>
{t("importProfile.reportUnrecoverable", { count: unrecoverable })}
</p>
)}
{report.warnings.map((code) => (
<p key={code} className="text-warning-text">
{t(`importProfile.warnings.${code}`)}
</p>
))}
</div>
);
}
/**
* Fold a retry's results back into the batch it came from.
*
* A retry only resubmits the items that failed, so the previous batch is still
* authoritative for every other row. Replacing it wholesale would make the
* successful imports disappear from the summary.
*/
function mergeImportResults(
previous: ProfileImportBatchResult,
retry: ProfileImportBatchResult,
): ProfileImportBatchResult {
const byPath = new Map(retry.results.map((item) => [item.source_path, item]));
const results = previous.results.map(
(item) => byPath.get(item.source_path) ?? item,
);
const count = (status: string) =>
results.filter((item) => item.status === status).length;
return {
imported_count: count("imported"),
skipped_count: count("skipped"),
failed_count: count("failed"),
results,
};
}
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
@@ -283,69 +361,99 @@ export function ImportProfileDialog({
}
};
const handleImport = useCallback(async () => {
if (selectedProfiles.length === 0) {
toast.error(t("importProfile.selectAtLeastOne"));
return;
}
if (
selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim())
) {
toast.error(t("importProfile.emptyNames"));
return;
}
const items: ImportProfileItem[] = selectedProfiles.map((p, index) => ({
source_path: p.path,
browser_type: p.browser,
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
proxy_id: proxyIdForIndex(index),
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
}));
setCurrentStep("importing");
setIsImporting(true);
setProgress(null);
setResult(null);
try {
const batchResult = await invoke<ProfileImportBatchResult>(
"import_browser_profiles",
{
items,
groupId: selectedGroupId === "none" ? null : selectedGroupId,
duplicateStrategy: duplicateStrategy,
wayfernConfig,
},
);
setResult(batchResult);
toast.success(
t("importProfile.resultsSummary", {
imported: batchResult.imported_count,
skipped: batchResult.skipped_count,
failed: batchResult.failed_count,
}),
);
if (batchResult.imported_count > 0 && !reducedMotion) {
fireSprinkleConfetti();
const handleImport = useCallback(
async (allowRunning = false, retryPaths?: ReadonlySet<string>) => {
if (selectedProfiles.length === 0) {
toast.error(t("importProfile.selectAtLeastOne"));
return;
}
} catch (error) {
console.error("Failed to import profiles:", error);
toast.error(translateBackendError(t, error));
setCurrentStep("configure");
} finally {
setIsImporting(false);
}
}, [
selectedProfiles,
profileNames,
proxyIdForIndex,
vpnAssignment,
selectedGroupId,
duplicateStrategy,
wayfernConfig,
reducedMotion,
t,
]);
if (
selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim())
) {
toast.error(t("importProfile.emptyNames"));
return;
}
// Filter AFTER the map, so a retry keeps the proxy each profile was
// originally assigned by the index-based round-robin.
const items: ImportProfileItem[] = selectedProfiles
.map((p, index) => ({
source_path: p.path,
browser_type: p.browser,
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
proxy_id: proxyIdForIndex(index),
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
allow_running: allowRunning,
}))
.filter((item) => !retryPaths || retryPaths.has(item.source_path));
if (items.length === 0) {
return;
}
setCurrentStep("importing");
setIsImporting(true);
setProgress(null);
// A retry covers only the failed subset, so the earlier results are still
// the truth for everything else and must not be thrown away.
const previous = retryPaths ? result : null;
setResult(null);
try {
const batchResult = await invoke<ProfileImportBatchResult>(
"import_browser_profiles",
{
items,
groupId: selectedGroupId === "none" ? null : selectedGroupId,
duplicateStrategy: duplicateStrategy,
wayfernConfig,
},
);
setResult(
previous ? mergeImportResults(previous, batchResult) : batchResult,
);
toast.success(
t("importProfile.resultsSummary", {
imported: batchResult.imported_count,
skipped: batchResult.skipped_count,
failed: batchResult.failed_count,
}),
);
if (batchResult.imported_count > 0 && !reducedMotion) {
fireSprinkleConfetti();
}
} catch (error) {
console.error("Failed to import profiles:", error);
toast.error(translateBackendError(t, error));
setCurrentStep("configure");
} finally {
setIsImporting(false);
}
},
[
selectedProfiles,
profileNames,
proxyIdForIndex,
vpnAssignment,
selectedGroupId,
duplicateStrategy,
wayfernConfig,
reducedMotion,
result,
t,
],
);
// A source browser that is still running is the one failure the user can fix
// without starting over, so offer the override right where it happened.
const hasRunningBrowserFailure = useMemo(
() =>
(result?.results ?? []).some(
(item) =>
item.status === "failed" &&
item.error?.includes("IMPORT_SOURCE_BROWSER_RUNNING"),
),
[result],
);
const handleClose = () => {
void cleanupExtractedDir(extractedDir);
@@ -840,38 +948,74 @@ export function ImportProfileDialog({
</h3>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{result.results.map((item) => (
<div
key={item.source_path}
className="flex items-center gap-2 p-1 text-sm"
>
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" && "text-success-text",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" &&
"text-destructive-text",
)}
>
{item.status === "imported" &&
t("importProfile.statusImported")}
{item.status === "skipped" &&
t("importProfile.statusSkipped")}
{item.status === "failed" &&
t("importProfile.statusFailed")}
</span>
<span className="min-w-0 flex-1 truncate">
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
{translateBackendError(t, new Error(item.error))}
<div key={item.source_path} className="p-1 text-sm">
<div className="flex items-center gap-2">
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" &&
"text-success-text",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" &&
"text-destructive-text",
)}
>
{item.status === "imported" &&
t("importProfile.statusImported")}
{item.status === "skipped" &&
t("importProfile.statusSkipped")}
{item.status === "failed" &&
t("importProfile.statusFailed")}
</span>
<span className="min-w-0 flex-1 truncate">
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
{translateBackendError(
t,
new Error(item.error),
)}
</span>
)}
</div>
{item.report && (
<ImportReportSummary report={item.report} />
)}
</div>
))}
</div>
{hasRunningBrowserFailure && (
<Alert>
<AlertDescription className="space-y-2">
<p>{t("importProfile.closeSourceBrowserHint")}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
void handleImport(
true,
new Set(
result.results
.filter(
(item) =>
item.status === "failed" &&
item.error?.includes(
"IMPORT_SOURCE_BROWSER_RUNNING",
),
)
.map((item) => item.source_path),
),
);
}}
>
{t("importProfile.importAnyway")}
</Button>
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
+449
View File
@@ -0,0 +1,449 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type {
ConsistencyResult,
DetectedVpnExtension,
ExtensionScanState,
} from "@/types";
import { RippleButton } from "./ui/ripple";
export interface GateFindings {
/// Extensions that could reroute traffic. A warning: the user may proceed.
vpnExtensions: DetectedVpnExtension[];
scanState: ExtensionScanState;
/// A measured exit/fingerprint mismatch. A block: the browser has not started.
fingerprint: ConsistencyResult | null;
/// An extension holds the proxy permission, so the exit measurement may not
/// describe the route the browser takes. A caveat on the block, not a waiver.
measurementUnreliable: boolean;
/// The exit has not been measured yet; the launch itself will still check.
probePending: boolean;
}
export interface GateDecision {
proceed: boolean;
ackFingerprint: boolean;
ackExtensionKeys: string[];
applyToRemaining: boolean;
}
interface PreLaunchGateDialogProps {
isOpen: boolean;
profileName: string;
profileId: string;
/// Identifies this specific request, so state resets even when one gate
/// replaces another without the dialog ever closing.
requestId: number;
findings: GateFindings | null;
/// How many further profiles are queued behind this one; >0 offers to apply
/// the same decision to all of them.
remainingCount: number;
/// The single exit route. Every path out of this dialog calls it exactly
/// once, so a caller awaiting a decision can never be left hanging.
onResult: (decision: GateDecision) => void;
}
/// Everything the user can change while one gate is on screen, stamped with
/// the gate it belongs to.
interface GateAnswerState {
requestId: number;
ackFingerprint: boolean;
ackExtensions: boolean;
applyToRemaining: boolean;
isMatching: boolean;
decided: boolean;
}
/// How long after a decision the footer stops accepting another one. Long
/// enough that a double-click cannot answer the gate promoted by its first
/// half, short enough that nobody deliberately answering two queued gates in a
/// row notices it.
const DECISION_COOLDOWN_MS = 500;
function answersFor(requestId: number): GateAnswerState {
return {
requestId,
ackFingerprint: false,
ackExtensions: false,
applyToRemaining: false,
isMatching: false,
decided: false,
};
}
function ExtensionEntry({ extension }: { extension: DetectedVpnExtension }) {
const { t } = useTranslation();
const capability = t(
extension.confidence === "confirmed"
? "prelaunchGate.vpnExtensionConfirmed"
: extension.confidence === "likely"
? "prelaunchGate.vpnExtensionLikely"
: "prelaunchGate.vpnExtensionCapability",
);
const source = t(
extension.source === "donut"
? "prelaunchGate.sourceDonut"
: "prelaunchGate.sourceBrowser",
);
return (
<li className="text-xs">
<span className="font-medium">{extension.name}</span>
<span className="text-muted-foreground">
{/* A version-less manifest is legal, and interpolating an empty string
into the one template left a doubled space before the dash. */}
{extension.version
? t("prelaunchGate.vpnExtensionEntry", {
version: extension.version,
capability,
source,
})
: t("prelaunchGate.vpnExtensionEntryNoVersion", {
capability,
source,
})}
</span>
</li>
);
}
export function PreLaunchGateDialog({
isOpen,
profileName,
profileId,
requestId,
findings,
remainingCount,
onResult,
}: PreLaunchGateDialogProps) {
const { t } = useTranslation();
// All mutable state is stamped with the request it belongs to, and anything
// stamped with an older request is ignored rather than reset. The dialog
// never unmounts and a queued gate promotes the next profile without ever
// closing it, so state carried across that boundary would tick a checkbox
// for a profile the user never saw — and `decided` carried across it left
// every button disabled on a dialog that also refused Escape, which is the
// freeze this shape exists to make unrepresentable.
//
// Deliberately not an effect keyed on `requestId`: a reset effect whose body
// reads none of its dependencies is exactly what a lint autofix reduces to
// `[]`, and that is how the freeze shipped.
const [state, setState] = useState<GateAnswerState>(() => answersFor(0));
const answers = state.requestId === requestId ? state : answersFor(requestId);
// The gate on screen right now, readable from an async callback whose
// closure was captured while an earlier gate was showing.
const liveRequestRef = useRef(requestId);
useEffect(() => {
liveRequestRef.current = requestId;
}, [requestId]);
const patch = (next: Partial<GateAnswerState>) => {
// A callback that resumes after its gate was answered must not write into
// the slot the next gate is now using — that would silently untick boxes
// the user has since ticked on a different profile.
if (liveRequestRef.current !== requestId) {
return;
}
setState((prev) => ({
...(prev.requestId === requestId ? prev : answersFor(requestId)),
...next,
requestId,
}));
};
const {
ackFingerprint,
ackExtensions,
applyToRemaining,
isMatching,
decided,
} = answers;
const fingerprint = findings?.fingerprint ?? null;
const extensions = findings?.vpnExtensions ?? [];
// Two different claims, kept visually apart. The first names extensions as
// VPN/proxy tools; the second says only that an extension holds Chromium's
// proxy permission, which a download manager needs to route its own
// transfers and which says nothing about what the extension is.
const vpnExtensions = extensions.filter((e) => e.confidence !== "capability");
const proxyCapableExtensions = extensions.filter(
(e) => e.confidence === "capability",
);
const mismatches = fingerprint?.mismatches ?? [];
const exitIp = fingerprint?.exit_ip ?? null;
const isBlocked = fingerprint !== null;
// Two guards, because answering a gate promotes the next one into the same
// DOM node rather than closing the dialog. The ref settles one gate exactly
// once even if two clicks land in the same React batch; the cooldown stops
// the second half of a double-click from answering a dialog that appeared
// between the two clicks and that nobody has read.
const decidedRef = useRef<number | null>(null);
const lastDecisionAtRef = useRef(Number.NEGATIVE_INFINITY);
const decide = (proceed: boolean) => {
if (decided || decidedRef.current === requestId) {
return;
}
const now = performance.now();
if (now - lastDecisionAtRef.current < DECISION_COOLDOWN_MS) {
return;
}
decidedRef.current = requestId;
lastDecisionAtRef.current = now;
patch({ decided: true });
onResult({
proceed,
ackFingerprint: ackFingerprint && isBlocked,
ackExtensionKeys: ackExtensions ? extensions.map((e) => e.key) : [],
applyToRemaining,
});
};
const handleMatchFingerprint = async () => {
if (!exitIp) {
return;
}
const request = requestId;
patch({ isMatching: true });
try {
await invoke("match_profile_fingerprint_to_exit", {
profileId,
exitIp,
});
showSuccessToast(t("consistencyWarning.matchSuccess"));
patch({ isMatching: false });
// Rewriting the fingerprint takes long enough for the user to dismiss
// this gate meanwhile. The profile change still stands, but the launch
// it belonged to is already settled, and deciding now would answer
// whichever gate took its place.
if (liveRequestRef.current !== request) {
return;
}
// The fingerprint the block was measured against no longer exists, so
// this launch is abandoned rather than forced through with a stale
// consent token; the user relaunches against the corrected profile.
decide(false);
} catch (e) {
showErrorToast(translateBackendError(t, e));
patch({ isMatching: false });
}
};
const scanNotice = (() => {
switch (findings?.scanState) {
case "encrypted":
return t("prelaunchGate.scanIncompleteEncrypted");
case "ephemeral":
return t("prelaunchGate.scanIncompleteEphemeral");
case "partial":
return t("prelaunchGate.scanIncompletePartial");
case "missing":
return t("prelaunchGate.scanIncompleteMissing");
default:
return null;
}
})();
return (
// Dismissible on purpose: cancelling is the safe outcome, so every way out
// of this dialog — Escape, the close X, a click outside — resolves the
// waiting launch as "don't start". A gate that can only be answered by two
// buttons is one disabled button away from trapping the whole app.
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
decide(false);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning-text" />
{isBlocked
? t("prelaunchGate.titleBlocked")
: t("prelaunchGate.titleWarning")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">
{t("prelaunchGate.intro", { name: profileName })}
</p>
{isBlocked && (
<div className="space-y-2 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="font-medium">
{t("prelaunchGate.fingerprintHeading")}
</p>
{mismatches.includes("timezone") && (
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.timezoneDetail", {
exit: fingerprint?.exit_timezone ?? "?",
fingerprint: fingerprint?.fingerprint_timezone ?? "?",
})}
</p>
)}
{mismatches.includes("language") && (
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.languageDetail", {
country: fingerprint?.exit_country_code ?? "?",
fingerprint: fingerprint?.fingerprint_language ?? "?",
})}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.explainer")}
</p>
</div>
)}
{vpnExtensions.length > 0 && (
<div className="space-y-2 rounded-md border border-warning/50 bg-warning/10 p-3">
<p className="font-medium">
{t("prelaunchGate.vpnExtensionHeading")}
</p>
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.vpnExtensionIntro")}
</p>
<ul className="space-y-1">
{vpnExtensions.map((ext) => (
<ExtensionEntry key={ext.key} extension={ext} />
))}
</ul>
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.vpnExtensionExplainer")}
</p>
</div>
)}
{proxyCapableExtensions.length > 0 && (
<div className="space-y-2 rounded-md border border-border bg-muted/40 p-3">
<p className="font-medium">
{t("prelaunchGate.proxyCapableHeading")}
</p>
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.proxyCapableIntro")}
</p>
<ul className="space-y-1">
{proxyCapableExtensions.map((ext) => (
<ExtensionEntry key={ext.key} extension={ext} />
))}
</ul>
</div>
)}
{findings?.measurementUnreliable && isBlocked && (
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.measurementUnreliable")}
</p>
)}
{findings?.probePending && !isBlocked && (
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.probePending")}
</p>
)}
{scanNotice && (
<p className="text-xs text-muted-foreground">{scanNotice}</p>
)}
<div className="space-y-2">
{isBlocked && (
<div className="flex items-center gap-x-2">
<Checkbox
id="gate-ack-fingerprint"
checked={ackFingerprint}
onCheckedChange={(v) => patch({ ackFingerprint: v === true })}
/>
<Label htmlFor="gate-ack-fingerprint" className="text-xs">
{t("prelaunchGate.dontBlockAgain")}
</Label>
</div>
)}
{extensions.length > 0 && (
<div className="flex items-center gap-x-2">
<Checkbox
id="gate-ack-extensions"
checked={ackExtensions}
onCheckedChange={(v) => patch({ ackExtensions: v === true })}
/>
<Label htmlFor="gate-ack-extensions" className="text-xs">
{t("prelaunchGate.dontWarnExtensions")}
</Label>
</div>
)}
{remainingCount > 0 && (
<div className="flex items-center gap-x-2">
<Checkbox
id="gate-apply-remaining"
checked={applyToRemaining}
onCheckedChange={(v) =>
patch({ applyToRemaining: v === true })
}
/>
<Label htmlFor="gate-apply-remaining" className="text-xs">
{t("prelaunchGate.applyToRemaining")}
</Label>
</div>
)}
</div>
</div>
<DialogFooter className="flex-row justify-between sm:justify-between">
{/* Cancel is the default action: the browser has not started, and
not starting it is the safe outcome. Never disabled by `decided`
`decide` is already idempotent, and the one control that ends
the dialog safely must not be something a stale flag can switch
off. */}
<RippleButton
variant="outline"
onClick={() => decide(false)}
disabled={isMatching}
autoFocus
>
{t("common.buttons.cancel")}
</RippleButton>
<div className="flex gap-2">
{isBlocked && exitIp && (
<RippleButton
variant="outline"
onClick={() => void handleMatchFingerprint()}
disabled={isMatching || decided}
>
{isMatching
? t("consistencyWarning.matching")
: t("consistencyWarning.matchToProxy")}
</RippleButton>
)}
<RippleButton
variant={isBlocked ? "destructive" : "default"}
onClick={() => decide(true)}
disabled={isMatching || decided}
>
{t("prelaunchGate.launchAnyway")}
</RippleButton>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+46 -10
View File
@@ -101,6 +101,7 @@ import { useBrowserState } from "@/hooks/use-browser-state";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useRemoteHandoff } from "@/hooks/use-remote-handoff";
import { useScrollFade } from "@/hooks/use-scroll-fade";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { useTeamLocks } from "@/hooks/use-team-locks";
@@ -121,6 +122,7 @@ import {
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
import { canUseCookieBot } from "@/lib/entitlements";
import { formatRelativeTime } from "@/lib/flag-utils";
import type { RemoteHandoffState } from "@/lib/remote-sessions";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type {
@@ -240,7 +242,7 @@ interface TableMeta {
setLaunchingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
setStoppingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
// Overflow actions
onAssignProfilesToGroup?: (profileIds: string[]) => void;
@@ -273,6 +275,15 @@ interface TableMeta {
isProfileLockedByAnother: (profileId: string) => boolean;
getProfileLockEmail: (profileId: string) => string | undefined;
// Remote execution.
//
// `getRemoteHandoff` is the authoritative answer to "can this be opened
// here", read from the same store the backend gate reads. The team-lock cache
// above cannot serve it: it refreshes on a 30-second poll and says nothing at
// all about a session that has finished but whose work has not been pulled
// back yet.
getRemoteHandoff: (profileId: string) => RemoteHandoffState | null;
// Synchronizer
getProfileSyncInfo: (profileId: string) =>
| {
@@ -1383,7 +1394,7 @@ BotCell.displayName = "BotCell";
interface ProfilesDataTableProps {
profiles: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onCloneProfile: (profile: BrowserProfile) => void | Promise<void>;
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
@@ -1585,6 +1596,10 @@ export function ProfilesDataTable({
const { vpnConfigs } = useVpnEvents();
const { user } = useCloudAuth();
const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id);
// Which profiles cannot be opened on this computer, and why. Event-driven and
// read from the backend's own gate, so the button state and the refusal the
// backend would give can never disagree.
const { handoffFor } = useRemoteHandoff();
// Cookie Bot. Enrolments and live runs both live server-side, so the table
// reads them from the shared store rather than from BrowserProfile.
@@ -2445,6 +2460,9 @@ export function ProfilesDataTable({
getProfileLockEmail: (profileId: string) =>
getLockInfo(profileId)?.lockedByEmail,
// Remote execution
getRemoteHandoff: handoffFor,
// Synchronizer
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
onLaunchWithSync:
@@ -2524,6 +2542,7 @@ export function ProfilesDataTable({
handleCreateCountryProxy,
isProfileLocked,
getLockInfo,
handoffFor,
getProfileSyncInfo,
onLaunchWithSync,
cookieBotUnlocked,
@@ -2725,20 +2744,37 @@ export function ProfilesDataTable({
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const handoff = meta.getRemoteHandoff(profile.id);
// A profile open on the fleet IS running, and the button has to say
// so: it is the control that stops it, and stopping now reaches the
// remote browser rather than looking for a local process that was
// never there.
const isRunningRemotely = handoff === "running";
const isPendingRemotePull = handoff === "pending_sync";
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
(meta.isClient && meta.runningProfiles.has(profile.id)) ||
isRunningRemotely;
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isLockedByAnother = meta.isProfileLockedByAnother(profile.id);
const isSyncing = meta.syncStatuses[profile.id]?.status === "syncing";
const canLaunch =
meta.browserState.canLaunchProfile(profile) &&
!isLockedByAnother &&
!isSyncing;
// A remote session holds the profile lock under its own holder id, so
// `isLockedByAnother` is true for the user's OWN fleet session. That
// must not disable the control that stops it.
const canLaunch = isRunningRemotely
? true
: meta.browserState.canLaunchProfile(profile) &&
!isPendingRemotePull &&
!isLockedByAnother &&
!isSyncing;
const lockEmail = meta.getProfileLockEmail(profile.id);
const tooltipContent = isLockedByAnother
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
: meta.browserState.getLaunchTooltipContent(profile);
const tooltipContent = isRunningRemotely
? meta.t("profiles.remote.runningTooltip")
: isPendingRemotePull
? meta.t("profiles.remote.pendingSyncTooltip")
: isLockedByAnother
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
: meta.browserState.getLaunchTooltipContent(profile);
const handleProfileStop = async (profile: BrowserProfile) => {
meta.setStoppingProfiles((prev: Set<string>) =>
+8 -1
View File
@@ -28,7 +28,9 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { translateBackendError } from "@/lib/backend-errors";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { showErrorToast } from "@/lib/toast-utils";
import type { BrowserProfile } from "@/types";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
import { RippleButton } from "./ui/ripple";
@@ -108,10 +110,15 @@ export function ProfileSelectorDialog({
await invoke("open_url_with_profile", {
profileId: selected.id,
url,
consentToken: null,
});
onClose();
} catch (error) {
console.error("Failed to open URL with profile:", error);
// This path reaches the browser without going through page.tsx's gate,
// so a launch the gate blocks surfaces here. Without a toast the deep
// link would simply appear to do nothing.
showErrorToast(translateBackendError(t, error));
} finally {
setIsLaunching(false);
if (selected) {
@@ -122,7 +129,7 @@ export function ProfileSelectorDialog({
});
}
}
}, [selectedProfile, url, onClose, profiles]);
}, [selectedProfile, url, onClose, profiles, t]);
const handleCancel = useCallback(() => {
setSelectedProfile(null);
+40 -4
View File
@@ -89,6 +89,12 @@ export function ProxyFormDialog({
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, setForm] = useState<ProxyFormData>(DEFAULT_FORM);
// The local parse only covers scheme/host/port. Whether Donut can actually
// use the server — REALITY, XTLS Vision, plain TCP — is decided by the Rust
// parser, so ask it (below) and show the specific reason while the user is
// still editing rather than after they save. Declared here because
// `handleSubmit` guards on it.
const [vlessUnsupported, setVlessUnsupported] = useState<string | null>(null);
const resetForm = useCallback(() => {
setForm(DEFAULT_FORM);
@@ -134,6 +140,11 @@ export function ProxyFormDialog({
return;
}
if (isVless && vlessUnsupported) {
toast.error(vlessUnsupported);
return;
}
if (!isVless && (!form.host.trim() || !form.port)) {
toast.error(t("proxies.form.hostPortRequired"));
return;
@@ -183,7 +194,7 @@ export function ProxyFormDialog({
} finally {
setIsSubmitting(false);
}
}, [editingProxy, form, onClose, t]);
}, [editingProxy, form, onClose, t, vlessUnsupported]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
@@ -193,12 +204,37 @@ export function ProxyFormDialog({
const isVless = form.proxy_type === "vless";
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
const trimmedVlessUri = form.vless_uri.trim();
useEffect(() => {
if (!isVless || trimmedVlessUri.length === 0) {
setVlessUnsupported(null);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
void invoke("validate_vless_uri", { uri: trimmedVlessUri })
.then(() => {
if (!cancelled) setVlessUnsupported(null);
})
.catch((error: unknown) => {
if (!cancelled) setVlessUnsupported(translateBackendError(t, error));
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [isVless, trimmedVlessUri, t]);
const hasInvalidVlessUri =
isVless && form.vless_uri.trim().length > 0 && !vlessEndpoint;
isVless &&
trimmedVlessUri.length > 0 &&
(!vlessEndpoint || vlessUnsupported !== null);
const isFormValid =
form.name.trim() &&
(isVless
? vlessEndpoint !== null
? vlessEndpoint !== null && vlessUnsupported === null
: form.host.trim() &&
form.port > 0 &&
form.port <= 65535 &&
@@ -286,7 +322,7 @@ export function ProxyFormDialog({
role={hasInvalidVlessUri ? "alert" : undefined}
>
{hasInvalidVlessUri
? t("proxies.form.vlessUriInvalid")
? (vlessUnsupported ?? t("proxies.form.vlessUriInvalid"))
: t("proxies.form.vlessUriHint")}
</p>
</div>

Some files were not shown because too many files have changed in this diff Show More