Skip to content

fix(sync): require explicit consent before a share syncs into a non-empty folder (#54)#58

Merged
psimaker merged 2 commits into
mainfrom
fix/issue-54-no-implicit-merge
Jul 7, 2026
Merged

fix(sync): require explicit consent before a share syncs into a non-empty folder (#54)#58
psimaker merged 2 commits into
mainfrom
fix/issue-54-no-implicit-merge

Conversation

@psimaker

@psimaker psimaker commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Closes #54.

The accept path guarded only against overlap with configured folders (#45): an unconfigured directory that already held content — a label-named folder under the root, the vault-as-root case, or a numeric-suffix candidate — was synced into silently, merging two content sets and pushing the mix to every offering peer. The manual picker (#52) already refused non-empty targets; the automatic path allowed the same merge implicitly.

Design (doctrine 002/006, new decision record 007): the automatic pass never merges — the share parks under Pending Shares with an explanation — and an explicit accept gets a confirmation dialog that names the consequence (contents combined and synced to the other devices). Confirming re-runs the accept with consent attached, re-validating everything at confirm time. A recorded manual target (#52) stays exempt as recorded consent, so remove + re-accept recovery keeps working. Silent diversion to a suffixed folder was rejected (006's rejected fallback — it would split a legitimate re-link).

What could go wrong, and why this is safe: enforced in two layers — the pure Swift decision core and the Go hard floor (AcceptPendingFolder refuses non-empty targets unless the confirmation travels through the new allowNonEmpty parameter; unreadable targets are refused, never assumed empty). Mirror tests on both sides pin the two emptiness rules (at most .obsidian) to identical decisions. Empty-vault targets, fresh directories, and all #45 overlap refusals behave exactly as before; overlap refusal always wins over consent.

Verified on Linux: Go suite green incl. the new floor tests (55s), gofmt clean, design-token lint green, all four Localizable.strings in sync (613 keys each). Pending: macOS xcodebuild test pass (CI builds its own xcframework). Note for local checkouts: the gomobile API changed — run 'cd go && make patch && make xcframework' before any local Xcode build, test run, or archive.

Follow-up noted: AddFolder does not have the same non-empty floor; it currently has no callers outside the thin wrapper chain, but deserves the symmetric guard.

This PR prevents pending shares from being silently merged into an existing non-empty folder.

  • Automatic acceptance now refuses non-empty targets and leaves the share in Pending Shares instead of syncing into an existing folder.
  • Manual acceptance now requires explicit merge confirmation before proceeding when the destination already contains files.
  • The safeguard is enforced in both the iOS decision flow and the Go acceptance layer, so no background sync path can bypass it.
  • Added localized user messaging for the new confirmation flow across supported languages.
  • Expanded test coverage for the non-empty-target guard, manual consent handling, and recovery cases for previously recorded targets.

This improves data-safety and privacy by avoiding unintended mixing of vault contents, with no new background behavior beyond the added confirmation gate.

…mpty folder (#54)

The accept path guarded only against overlap with configured folders (#45):
an unconfigured directory that already held content — a label-named folder
under the root, the vault-as-root case, or a numeric-suffix candidate — was
synced into silently, merging two content sets and pushing the mix to every
offering peer. The manual picker (#52) already refused non-empty targets;
the automatic path allowed the same merge implicitly.

Per doctrine 002/006 (decision record 007, added here): the automatic pass
never merges — the share parks under Pending Shares with an explanation —
and an explicit accept gets a confirmation dialog that names the consequence
(contents combined and synced to the other devices sharing the vault).
Confirming re-runs the accept with consent attached and re-validates
everything at confirm time. A recorded manual target (#52) stays exempt as
recorded consent, so remove + re-accept recovery keeps working; silent
diversion to a suffixed folder was rejected — it would split a legitimate
re-link (006's rejected fallback).

Enforced in two layers: the pure Swift decision core (resolveAcceptPath,
injected listing) and the Go hard floor (AcceptPendingFolder refuses
non-empty targets — at most .obsidian — unless the confirmation travels
through the new allowNonEmpty parameter; unreadable targets are refused,
never assumed empty). Mirror tests on both sides pin the two emptiness
rules to identical decisions. Existing empty-vault targets, fresh
directories, and all #45 overlap refusals behave exactly as before.

Verified here: Go suite green (go test -tags noassets ./bridge, 55s,
incl. the new floor tests), gofmt clean, design-token lint green, all four
Localizable.strings key counts identical (613). Swift suite not runnable on
Linux — pending the macOS test pass. The gomobile API change requires an
xcframework rebuild (make xcframework, macOS) before any local Xcode build,
test run, or archive.
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@psimaker, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f4225c31-441d-43d7-a417-1b593f64fbeb

📥 Commits

Reviewing files that changed from the base of the PR and between 22c522c and f9f9188.

📒 Files selected for processing (1)
  • ios/project.yml
📝 Walkthrough

Walkthrough

Adds an explicit merge-confirmation gate to prevent syncing a pending share into a non-empty destination folder. Enforcement spans a Go bridge hard floor (AcceptPendingFolder/nonEmptyTargetError), a Swift resolver (resolveAcceptPath), new outcome types, a confirmation dialog UI, localization strings, a decision doc, changelog entry, and tests.

Changes

Implicit merge guard

Layer / File(s) Summary
Decision record and changelog
docs/decisions/007-no-implicit-merge-on-accept.md, CHANGELOG.md
Documents the rule against implicit merges and records it as an Unreleased fix for issue #54.
Go hard floor
go/bridge/pendingfolders.go, go/bridge/pendingfolders_test.go
AcceptPendingFolder gains allowNonEmpty; new nonEmptyTargetError refuses non-empty targets (beyond .obsidian) unless confirmed; tests updated/expanded accordingly.
Swift bridge plumbing
ios/VaultSync/Services/SyncBridgeService.swift, ios/VaultSync/Services/SyncthingManager.swift
Threads allowNonEmpty through the Swift bridge layers down to the Go call.
VaultManager resolver and outcome contract
ios/VaultSync/Services/VaultManager.swift
resolveAcceptPath gains mergeConfirmed/listingFor to detect non-empty targets and return requiresMergeConfirmation; acceptPendingShare returns a new PendingShareAcceptOutcome including needsMergeConfirmation.
UI dialog and localization
ios/VaultSync/Views/ContentView.swift, ios/VaultSync/*.lproj/Localizable.strings
Adds a confirmation dialog, state, and handler to re-run acceptance with mergeConfirmed: true; adds localized strings in en/de/es/zh-Hans.
Resolver tests
ios/VaultSyncTests/ImplicitMergeGuardTests.swift, ios/VaultSyncTests/ManualShareTargetTests.swift
New test suite covers emptiness rule, confirmation override, manual-target exemption, overlap/compromised-root refusal, and suffix-candidate guard; existing test helper updated for new parameters.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

  • psimaker/vaultsync#47: Both PRs modify the pending-share acceptance guard in go/bridge/pendingfolders.go to prevent silent data-mixing into existing directories.

Poem

A folder full, a share on hold,
No silent merge, however bold. 🐇
"Pending Shares" now stand and wait,
Till you confirm the merge is straight.
Go and Swift agree as one—
No mixing files till you say "done."

🚥 Pre-merge checks | ✅ 7
✅ Passed checks (7 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is a clear conventional-commit-style summary of the merge-consent change and references the linked issue.
Linked Issues check ✅ Passed The PR adds explicit merge confirmation and blocks automatic syncs into non-empty targets, matching #54's requirement.
Out of Scope Changes check ✅ Passed The changes stay focused on the non-empty target merge guard, with matching docs, tests, and localizations.
No Private Note Leakage ✅ Passed PASS: The new accept flow only uses local file checks; no analytics/network APIs were added, and new logs redact path/target values with privacy annotations.
Bounded Ios Background Work ✅ Passed PASS: PR-touched Swift files contain no background-task code; existing BackgroundSyncService already has deadlines, Task.isCancelled, and expiration handlers, and it wasn’t changed.
Bridge Contract Compatibility ✅ Passed PASS: Go AcceptPendingFolder still returns empty-string success, JSON endpoints unchanged, and Swift wrappers/callers/tests were updated to the added bool without shape/type mismatches.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-54-no-implicit-merge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ons stay locale-stable (#52)

ManualShareTargetTests pins the #52 refusal semantics by asserting English
message fragments (path, reason, next step). The test runner inherits the
host/simulator language, so on a German-locale machine L10n.tr resolves to
de.lproj and 10 assertions fail although the refusal behavior is correct.

Pin the language at scheme level (project.yml, test.language: en) instead of
weakening assertions or passing -testLanguage per invocation: the local
verification command and the CI macOS job both use -scheme VaultSync, so one
pin covers both and no command line changes. Test-only scheme option — app
behavior, shipped strings, and the #52 assertions are untouched. Verified on
a de-DE host: full suite 178/178 green.
@psimaker psimaker merged commit db09b6f into main Jul 7, 2026
13 checks passed
@psimaker psimaker deleted the fix/issue-54-no-implicit-merge branch July 7, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Label-default accept can sync into an existing non-empty, unconfigured folder (implicit merge)

1 participant